From 956f33140e71f926d63db5ad02e7d782722f0b93 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 11 Jul 2026 00:27:54 +0200 Subject: [PATCH] chore: automate Android release builds --- .gitignore | 1 + README.md | 20 ++ scripts/release-android.sh | 363 +++++++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100755 scripts/release-android.sh diff --git a/.gitignore b/.gitignore index 7bd3dba..db3ff65 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ app/google-services.json:Zone.Identifier *.iml keystore.properties *.jks +release-artifacts/ diff --git a/README.md b/README.md index bab89c5..ed693b2 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,23 @@ Natywna aplikacja Android Jetpack Compose dla kierowcow TPP. ```bash ./gradlew :app:testDebugUnitTest ``` + +## Release Gitea i Google Play + +Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie uruchom: + +```bash +./scripts/release-android.sh +``` + +Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy, +buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji, +tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w +`release-artifacts//`. + +Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper. +Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić: + +```bash +./scripts/release-android.sh --reuse-current +``` diff --git a/scripts/release-android.sh b/scripts/release-android.sh new file mode 100755 index 0000000..23491a2 --- /dev/null +++ b/scripts/release-android.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION_FILE="$ROOT_DIR/app/build.gradle.kts" +REMOTE_NAME="${GIT_REMOTE:-origin}" +NOTES_FILE="" +SKIP_TESTS=0 +REUSE_CURRENT=0 +VERSION_FILE_CHANGED=0 +VERSION_COMMITTED=0 +VERSION_BACKUP="" +RELEASE_SUCCEEDED=0 + +usage() { + cat <<'EOF' +Użycie: scripts/release-android.sh [opcje] + +Buduje i publikuje kolejną wersję aplikacji kierowcy: + - automatycznie zwiększa versionCode o 1 i patch versionName o 1, + - uruchamia testy jednostkowe, + - buduje podpisany APK dla Gitea Release, + - buduje podpisany AAB dla Google Play Console, + - zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push, + - tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS. + +Opcje: + --notes-file PLIK Treść release notes z podanego pliku. + --skip-tests Pomiń testDebugUnitTest. + --reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej + obecnie w app/build.gradle.kts. + -h, --help Pokaż pomoc. + +Zmienne środowiskowe: + GITEA_TOKEN Token API Gitea. Jeśli go brak, używany jest git credential helper. + GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com. + GITEA_REPOSITORY Nadpisuje owner/repository. + GIT_REMOTE Remote Git, domyślnie origin. + +Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts. +Google Play Console otrzymuje plik AAB, ponieważ jest to właściwy format publikacyjny. +EOF +} + +while (($# > 0)); do + case "$1" in + --notes-file) + NOTES_FILE="${2:-}" + [[ -n "$NOTES_FILE" ]] || { echo "Brak wartości dla --notes-file." >&2; exit 2; } + shift 2 + ;; + --skip-tests) + SKIP_TESTS=1 + shift + ;; + --reuse-current) + REUSE_CURRENT=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Nieznana opcja: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cd "$ROOT_DIR" + +for command in git curl jq perl sha256sum jarsigner; do + command -v "$command" >/dev/null 2>&1 || { + echo "Brak wymaganego polecenia: $command" >&2 + exit 1 + } +done + +[[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; } +[[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&2; exit 1; } +[[ -f "$VERSION_FILE" ]] || { echo "Brak $VERSION_FILE." >&2; exit 1; } + +branch="$(git branch --show-current)" +[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; } + +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Repozytorium ma niezapisane zmiany śledzonych plików. Najpierw wykonaj commit." >&2 + exit 1 +fi + +relevant_untracked="$(git status --porcelain --untracked-files=all -- app gradle scripts build.gradle.kts settings.gradle.kts gradle.properties | awk '$1 == "??" { print }')" +if [[ -n "$relevant_untracked" ]]; then + echo "Repozytorium ma nieśledzone pliki źródłowe. Najpierw zdecyduj, czy mają wejść do release:" >&2 + printf '%s\n' "$relevant_untracked" >&2 + exit 1 +fi + +git fetch --tags "$REMOTE_NAME" + +remote_url="$(git remote get-url "$REMOTE_NAME")" +sanitized_remote="$(printf '%s' "$remote_url" | sed -E 's#^(https?://)[^/@]+@#\1#')" +if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then + detected_protocol="${BASH_REMATCH[1]}" + detected_host="${BASH_REMATCH[2]}" + detected_repository="${BASH_REMATCH[3]%.git}" +else + detected_protocol="https" + detected_host="" + detected_repository="" +fi + +gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}" +gitea_repository="${GITEA_REPOSITORY:-$detected_repository}" +gitea_base_url="${gitea_base_url%/}" +[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; } +[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; } + +auth_args=() +if [[ -n "${GITEA_TOKEN:-}" ]]; then + auth_args=(-H "Authorization: token $GITEA_TOKEN") +else + credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)" + gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')" + gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')" + [[ -n "$gitea_username" && -n "$gitea_password" ]] || { + echo "Brak danych Gitea. Ustaw GITEA_TOKEN albo skonfiguruj git credential helper." >&2 + exit 1 + } + auth_args=(-u "$gitea_username:$gitea_password") +fi + +api_base="$gitea_base_url/api/v1/repos/$gitea_repository" +preflight_status="$(curl -sS "${auth_args[@]}" -o /dev/null -w '%{http_code}' "$api_base")" +[[ "$preflight_status" == "200" ]] || { + echo "Brak dostępu do repozytorium Gitea $gitea_repository (HTTP $preflight_status)." >&2 + exit 1 +} + +read_version() { + local code name + code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+).*$/\1/p' "$VERSION_FILE" | head -n 1)" + name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)".*$/\1/p' "$VERSION_FILE" | head -n 1)" + [[ "$code" =~ ^[0-9]+$ ]] || { echo "Nie można odczytać versionCode." >&2; return 1; } + [[ "$name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "versionName musi mieć format major.minor.patch." >&2; return 1; } + printf '%s %s\n' "$code" "$name" +} + +read -r current_code current_name < <(read_version) +if ((REUSE_CURRENT == 1)); then + next_code="$current_code" + next_name="$current_name" +else + IFS=. read -r version_major version_minor version_patch <<<"$current_name" + next_code=$((current_code + 1)) + next_name="${version_major}.${version_minor}.$((version_patch + 1))" +fi + +tag_name="v${next_name}-${next_code}" +artifact_dir="$ROOT_DIR/release-artifacts/$tag_name" +gitea_apk_name="TPP-Kierowca-${next_name}-${next_code}.apk" +play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab" + +cleanup_on_exit() { + local exit_code=$? + if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then + cp "$VERSION_BACKUP" "$VERSION_FILE" + echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2 + fi + [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" + if ((RELEASE_SUCCEEDED == 0)); then + echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2 + fi + trap - EXIT INT TERM + exit "$exit_code" +} +trap cleanup_on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if ((REUSE_CURRENT == 0)); then + if git show-ref --verify --quiet "refs/tags/$tag_name" || git ls-remote --exit-code --tags "$REMOTE_NAME" "refs/tags/$tag_name" >/dev/null 2>&1; then + echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2 + exit 1 + fi + + VERSION_BACKUP="$(mktemp)" + cp "$VERSION_FILE" "$VERSION_BACKUP" + perl -0pi -e "s/versionCode = \\d+/versionCode = $next_code/" "$VERSION_FILE" + perl -0pi -e "s/versionName = \"[^\"]+\"/versionName = \"$next_name\"/" "$VERSION_FILE" + VERSION_FILE_CHANGED=1 + + read -r written_code written_name < <(read_version) + [[ "$written_code" == "$next_code" && "$written_name" == "$next_name" ]] || { + echo "Nie udało się jednoznacznie zapisać nowej wersji." >&2 + exit 1 + } +fi + +echo "Buduję TPP Kierowca $next_name ($next_code)..." +gradle_tasks=(assembleRelease bundleRelease) +if ((SKIP_TESTS == 0)); then + gradle_tasks=(testDebugUnitTest "${gradle_tasks[@]}") +fi +./gradlew "${gradle_tasks[@]}" + +apk_source="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name '*.apk' -print -quit)" +aab_source="$(find app/build/outputs/bundle/release -maxdepth 1 -type f -name '*.aab' -print -quit)" +[[ -n "$apk_source" && -f "$apk_source" ]] || { echo "Nie znaleziono release APK." >&2; exit 1; } +[[ -n "$aab_source" && -f "$aab_source" ]] || { echo "Nie znaleziono release AAB." >&2; exit 1; } + +sdk_dir="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$sdk_dir" && -f local.properties ]]; then + sdk_dir="$(sed -n 's/^sdk.dir=//p' local.properties | head -n 1)" +fi +apksigner="$(find "$sdk_dir/build-tools" -type f -name apksigner 2>/dev/null | sort -V | tail -n 1)" +aapt="$(find "$sdk_dir/build-tools" -type f -name aapt 2>/dev/null | sort -V | tail -n 1)" +[[ -x "$apksigner" && -x "$aapt" ]] || { echo "Nie znaleziono apksigner/aapt w Android SDK." >&2; exit 1; } +"$apksigner" verify --verbose "$apk_source" +apk_badging="$("$aapt" dump badging "$apk_source" | head -n 1)" +[[ "$apk_badging" == *"versionCode='$next_code'"* && "$apk_badging" == *"versionName='$next_name'"* ]] || { + echo "APK ma inny numer wersji niż oczekiwany $next_name ($next_code)." >&2 + exit 1 +} +jarsigner -verify "$aab_source" >/dev/null + +rm -rf "$artifact_dir" +mkdir -p "$artifact_dir/gitea" "$artifact_dir/google-play" +cp "$apk_source" "$artifact_dir/gitea/$gitea_apk_name" +cp "$aab_source" "$artifact_dir/google-play/$play_aab_name" +( + cd "$artifact_dir" + sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt +) + +if ((REUSE_CURRENT == 0)); then + git add "$VERSION_FILE" + git diff --cached --check + git commit -m "chore: release android driver $next_name ($next_code)" + VERSION_COMMITTED=1 +fi + +git push "$REMOTE_NAME" "$branch" +if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then + git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)" +fi +tag_commit="$(git rev-list -n 1 "$tag_name")" +head_commit="$(git rev-parse HEAD)" +[[ "$tag_commit" == "$head_commit" ]] || { + echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2 + exit 1 +} +git push "$REMOTE_NAME" "$tag_name" + +if [[ -n "$NOTES_FILE" ]]; then + [[ -f "$NOTES_FILE" ]] || { echo "Nie istnieje plik release notes: $NOTES_FILE" >&2; exit 1; } + release_notes="$(<"$NOTES_FILE")" +else + previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | head -n 1 || true)" + if [[ -n "$previous_tag" ]]; then + changes="$(git log --format='- %s (%h)' "$previous_tag..HEAD")" + else + changes="$(git log -20 --format='- %s (%h)')" + fi + release_notes="TPP Kierowca $next_name ($next_code) + +Zmiany: +${changes:-'- Wydanie techniczne.'} + +Artefakt Google Play Console (AAB) znajduje się lokalnie w: +release-artifacts/$tag_name/google-play/$play_aab_name" +fi + +release_response="$(mktemp)" +release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")" +if [[ "$release_status" == "200" ]]; then + release_id="$(jq -r '.id' "$release_response")" +elif [[ "$release_status" == "404" ]]; then + release_payload="$(jq -n \ + --arg tag "$tag_name" \ + --arg target "$branch" \ + --arg name "TPP Kierowca $next_name ($next_code)" \ + --arg body "$release_notes" \ + '{tag_name:$tag,target_commitish:$target,name:$name,body:$body,draft:false,prerelease:false}')" + release_status="$(curl -sS "${auth_args[@]}" \ + -H 'Content-Type: application/json' \ + -o "$release_response" \ + -w '%{http_code}' \ + -X POST \ + -d "$release_payload" \ + "$api_base/releases")" + [[ "$release_status" == "201" ]] || { + echo "Nie udało się utworzyć Gitea Release (HTTP $release_status)." >&2 + jq '{message,errors}' "$release_response" >&2 || true + exit 1 + } + release_id="$(jq -r '.id' "$release_response")" +else + echo "Nie udało się sprawdzić Gitea Release (HTTP $release_status)." >&2 + exit 1 +fi + +upload_asset() { + local file_path="$1" + local asset_name="$2" + local mime_type="$3" + local assets_response existing_asset_id upload_response upload_status + assets_response="$(mktemp)" + curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets" + existing_asset_id="$(jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' "$assets_response" | head -n 1)" + if [[ -n "$existing_asset_id" ]]; then + curl -fsS "${auth_args[@]}" -X DELETE "$api_base/releases/$release_id/assets/$existing_asset_id" >/dev/null + fi + upload_response="$(mktemp)" + upload_status="$(curl -sS "${auth_args[@]}" \ + -o "$upload_response" \ + -w '%{http_code}' \ + -X POST \ + -F "attachment=@$file_path;type=$mime_type" \ + "$api_base/releases/$release_id/assets?name=$asset_name")" + [[ "$upload_status" == "201" ]] || { + echo "Nie udało się wysłać $asset_name (HTTP $upload_status)." >&2 + jq '{message,errors}' "$upload_response" >&2 || true + return 1 + } +} + +upload_asset "$artifact_dir/gitea/$gitea_apk_name" "$gitea_apk_name" "application/vnd.android.package-archive" +upload_asset "$artifact_dir/SHA256SUMS.txt" "SHA256SUMS.txt" "text/plain" + +assets_response="$(mktemp)" +curl -fsS "${auth_args[@]}" -o "$assets_response" "$api_base/releases/$release_id/assets" +published_apk_url="$(jq -r --arg name "$gitea_apk_name" '.[] | select(.name == $name) | .browser_download_url' "$assets_response" | head -n 1)" +[[ -n "$published_apk_url" && "$published_apk_url" != "null" ]] || { + echo "Nie znaleziono opublikowanego APK w Gitea Release." >&2 + exit 1 +} +downloaded_apk="$(mktemp)" +curl -fsSL "${auth_args[@]}" -o "$downloaded_apk" "$published_apk_url" +local_apk_hash="$(sha256sum "$artifact_dir/gitea/$gitea_apk_name" | awk '{print $1}')" +published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')" +[[ "$local_apk_hash" == "$published_apk_hash" ]] || { + echo "Checksum opublikowanego APK nie zgadza się z lokalnym plikiem." >&2 + exit 1 +} + +release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name" +RELEASE_SUCCEEDED=1 +trap - EXIT INT TERM +[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" + +cat <