diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4096fe1..fdc7b00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: exit 1 fi "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" + - run: ruby scripts/test-play-release.rb - run: bundle exec fastlane android checks - name: Publish test and lint reports if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d039919..b70ec32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,13 +80,20 @@ jobs: retention-days: 14 publish: - name: Publish GitHub Release + name: Publish GitHub Release and Google Play draft needs: release if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true - uses: actions/download-artifact@v8 with: name: message487-signed-release @@ -100,3 +107,7 @@ jobs: gh release create "$GITHUB_REF_NAME" ./*.apk ./*.aab mapping.txt SHA256SUMS \ --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes \ --title "Message487 $GITHUB_REF_NAME" + - name: Upload Google Play internal draft + env: + SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }} + run: bundle exec fastlane android play_release track:internal release_status:draft diff --git a/docs/en/releases.md b/docs/en/releases.md index 97d4f80..311d899 100644 --- a/docs/en/releases.md +++ b/docs/en/releases.md @@ -13,7 +13,7 @@ flag, and verifies the certificate and signature of every AAB payload entry. | --- | --- | | `message487-.apk` | Versioned signed APK | | `message487.apk` | Byte-identical APK with a stable download filename | -| `message487.aab` | Signed Android App Bundle for manual Play Console upload | +| `message487.aab` | Signed Android App Bundle for Google Play | | `mapping.txt` | R8 mapping for this exact build | | `SHA256SUMS` | Checksums for both APK names, AAB and mapping | @@ -77,8 +77,55 @@ The first configured version is `0.0.1` with `versionCode = 1`. Later releases m for `RECEIVE_SMS`; a successful AAB build does not establish store eligibility. 5. Review the internal release in Play Console before rolling it out. -CI builds and verifies the artifacts; it does not upload to Google Play or require a Play service -account. Native symbol packaging from MegaProxy is unnecessary here: this app has no native core. +### Fastlane upload + +Like MegaProxy, `play_release` uploads existing signed artifacts and changelogs as an **internal +draft** by default. Message487 uploads `mapping.txt` (R8), rather than native symbols. +Build both files together with `release_artifacts`; the upload lane does not build them or verify +their signatures or embedded versions. Use an unused, increasing `versionCode` for each upload. + +```shell +bundle exec fastlane android release_artifacts +bundle exec fastlane android play_release dry_run:true +``` + +`dry_run:true` checks options and readable, non-empty AAB/mapping files locally and prints the +destination. It never calls Google, even when combined with `validate_only:true`. It does not +check Google permissions, version-code availability, signatures or store eligibility. + +For API access, enable the Google Play Developer API and grant a dedicated service account access +to `life.andre.message487` and the intended tracks in Play Console. Keep its JSON key outside the +repository. Supply the complete JSON via `SUPPLY_JSON_KEY_DATA`, not as a lane argument or Base64. +For example, export it in a private `~/.config/message487/release.env` file with mode `0600`: + +```shell +source "$HOME/.config/message487/release.env" +bundle exec fastlane android play_release validate_only:true +# Create the internal draft only when ready: +bundle exec fastlane android play_release +``` + +`validate_only:true` uploads into a temporary Google Play edit and validates it without committing +the release. It requires credentials and network access; it is not an offline dry run. +See [Fastlane supply](https://docs.fastlane.tools/actions/upload_to_play_store/). + +Options: `aab:`, `mapping:`, `track:`, `release_status:draft|completed`, `validate_only:true|false` +and `dry_run:true|false`. Default files are `dist/release/message487.aab` and +`dist/release/mapping.txt`; `MESSAGE487_RELEASE_DIR` overrides that directory. Relative paths +resolve from the repository root. `track:production release_status:completed` requests a production +release; review and managed publishing may still delay availability. The release label uses +`versionName` from the current checkout, so use artifacts built from that checkout. + +Changelogs come from `fastlane/metadata/android//changelogs/.txt`. +Descriptions, images and screenshots are not uploaded by this lane; category and tags remain +manual settings documented in [Branding](../../assets/branding/README.md). +Run `ruby scripts/test-play-release.rb` for offline regression checks (also run in PR CI). + +On a `v*` tag push, CI builds and verifies artifacts, publishes the GitHub Release, then uploads +an internal Google Play draft using the repository Actions secret `SUPPLY_JSON_KEY_DATA`. +The secret is passed only to the upload step. `release-check/*` and manual workflow dispatch +build artifacts only. Creating a GitHub Release manually does not trigger this workflow. +A failed Play upload fails the job but leaves the published GitHub Release available. Native symbol packaging from MegaProxy is unnecessary here: this app has no native core. The bundle signature verifier also rejects unsigned added entries, modified entries, missing required bundle entries and unexpected certificates; its regression fixtures run in Android CI. diff --git a/docs/ru/releases.md b/docs/ru/releases.md index 8c8f1da..d5b40e1 100644 --- a/docs/ru/releases.md +++ b/docs/ru/releases.md @@ -13,7 +13,7 @@ APK, а также сертификат и подпись каждого сод | --- | --- | | `message487-.apk` | Подписанный APK с версией в имени | | `message487.apk` | Побайтовая копия APK с постоянным именем для скачивания | -| `message487.aab` | Подписанный Android App Bundle для ручной загрузки в Play Console | +| `message487.aab` | Подписанный Android App Bundle для Google Play | | `mapping.txt` | R8 mapping именно этой сборки | | `SHA256SUMS` | Контрольные суммы обоих APK, AAB и mapping | @@ -78,8 +78,58 @@ Workflow восстанавливает ключ и пароль с приват Успешная сборка AAB сама по себе не означает соответствие правилам магазина. 5. Проверьте внутренний релиз в Play Console перед распространением. -CI собирает и проверяет файлы; автоматическая загрузка в Google Play и сервисный аккаунт -Play не настроены. Архив нативных символов из MegaProxy здесь не нужен: у приложения нет +### Загрузка через Fastlane + +Как в MegaProxy, `play_release` загружает готовые подписанные артефакты и списки изменений, +по умолчанию создавая **черновик внутреннего тестирования**. Вместо нативных символов +Message487 передаёт `mapping.txt` для R8. Собирайте оба файла вместе через `release_artifacts`: +lane загрузки не собирает их и не проверяет подписи или встроенные версии. +Для каждой загрузки нужен новый, возрастающий `versionCode`. + +```shell +bundle exec fastlane android release_artifacts +bundle exec fastlane android play_release dry_run:true +``` + +`dry_run:true` локально проверяет параметры и наличие доступных непустых AAB/mapping, затем +выводит назначение загрузки. Обращений к Google нет, даже вместе с `validate_only:true`. +Доступ к Play, доступность versionCode, подписи и соответствие правилам магазина он не проверяет. + +Для API включите Google Play Developer API и предоставьте отдельному сервисному аккаунту +доступ к `life.andre.message487` и нужным трекам в Play Console. JSON-ключ храните вне +репозитория. Передавайте его целиком через `SUPPLY_JSON_KEY_DATA`, не аргументом lane и не +в Base64. Например, экспортируйте переменную в приватном файле +`~/.config/message487/release.env` с правами `0600`: + +```shell +source "$HOME/.config/message487/release.env" +bundle exec fastlane android play_release validate_only:true +# Создать внутренний черновик, когда всё готово: +bundle exec fastlane android play_release +``` + +`validate_only:true` загружает данные во временную транзакцию Google Play и проверяет их, +не сохраняя релиз. Нужны ключ и сеть; это не локальный dry run. +См. [Fastlane supply](https://docs.fastlane.tools/actions/upload_to_play_store/). + +Параметры: `aab:`, `mapping:`, `track:`, `release_status:draft|completed`, +`validate_only:true|false`, `dry_run:true|false`. Файлы по умолчанию — +`dist/release/message487.aab` и `dist/release/mapping.txt`; каталог переопределяет +`MESSAGE487_RELEASE_DIR`. Относительные пути считаются от корня репозитория. +`track:production release_status:completed` запрашивает production-релиз; проверка Google и +управляемая публикация могут задержать доступность. Название релиза использует `versionName` +текущего checkout, поэтому берите артефакты, собранные из него. + +Списки изменений берутся из `fastlane/metadata/android//changelogs/.txt`. +Описания, изображения и скриншоты этот lane не загружает; категория и теги задаются вручную +по [Branding](../../assets/branding/README.md). Локальная регрессионная проверка: +`ruby scripts/test-play-release.rb` (также запускается в PR CI). + +При отправке тега `v*` CI собирает и проверяет артефакты, публикует GitHub Release, затем +загружает внутренний черновик Google Play с секретом репозитория `SUPPLY_JSON_KEY_DATA`. +Ключ передаётся только шагу загрузки. `release-check/*` и ручной запуск workflow только +собирают файлы. Создание GitHub Release вручную не запускает этот workflow. +Ошибка загрузки Play завершает job с ошибкой, но опубликованный GitHub Release остаётся доступен. Архив нативных символов из MegaProxy здесь не нужен: у приложения нет нативного ядра. Проверка подписи бандла отвергает добавленные неподписанные файлы, изменённые файлы, отсутствие обязательных частей и чужой сертификат; регрессионные тесты этой проверки входят в Android CI. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 54c77ec..0a105f1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -66,4 +66,77 @@ platform :android do lane :install do gradle(task: "installDebug", project_dir: project_root) end + + desc "Upload a signed release AAB and R8 mapping to Google Play (internal draft by default)" + lane :play_release do |options| + allowed_options = %i[aab mapping track release_status validate_only dry_run] + unknown_options = options.keys - allowed_options + UI.user_error!("Unknown play_release options: #{unknown_options.join(', ')}") unless unknown_options.empty? + + track = options.fetch(:track, "internal").to_s + UI.user_error!("track must not be empty") if track.strip.empty? + release_status = options.fetch(:release_status, "draft").to_s + unless %w[draft completed].include?(release_status) + UI.user_error!("release_status must be draft or completed") + end + validate_only = options.fetch(:validate_only, false).to_s + unless %w[true false].include?(validate_only) + UI.user_error!("validate_only must be true or false") + end + + release_dir = File.expand_path(ENV.fetch("MESSAGE487_RELEASE_DIR", "dist/release"), project_root) + files = { + aab: File.expand_path(options.fetch(:aab, File.join(release_dir, "message487.aab")), project_root), + mapping: File.expand_path(options.fetch(:mapping, File.join(release_dir, "mapping.txt")), project_root) + } + files.each do |name, path| + unless File.file?(path) && File.readable?(path) && File.size?(path) + UI.user_error!("#{name} must be a readable, non-empty file: #{path}") + end + end + UI.user_error!("aab must have the .aab extension") unless File.extname(files[:aab]) == ".aab" + UI.user_error!("mapping must have the .txt extension") unless File.extname(files[:mapping]) == ".txt" + + app_version = File.read(File.join(project_root, "app/build.gradle.kts"))[/^\s*versionName = "([^"]+)"/, 1] + UI.user_error!("Could not read versionName from app/build.gradle.kts") unless app_version + + dry_run = options.fetch(:dry_run, false).to_s + UI.user_error!("dry_run must be true or false") unless %w[true false].include?(dry_run) + if dry_run == "true" + UI.success("Dry run: #{files[:aab]} + #{files[:mapping]} -> life.andre.message487, #{track}, #{release_status}, Version #{app_version}") + UI.message("Local file/option checks only; no Google API calls, upload or signature/version validation") + next + end + + json_key_data = ENV["SUPPLY_JSON_KEY_DATA"] + if json_key_data.to_s.strip.empty? + UI.user_error!("Set SUPPLY_JSON_KEY_DATA to the service-account JSON contents") + end + begin + credentials = JSON.parse(json_key_data) + rescue JSON::ParserError + UI.user_error!("SUPPLY_JSON_KEY_DATA must contain valid JSON") + end + unless credentials.is_a?(Hash) && credentials["type"] == "service_account" && + %w[client_email private_key token_uri].all? { |key| credentials[key].is_a?(String) && !credentials[key].strip.empty? } + UI.user_error!("SUPPLY_JSON_KEY_DATA must contain a service-account key with client_email, private_key and token_uri") + end + + upload_to_play_store( + **files, + json_key_data: json_key_data, + package_name: "life.andre.message487", + version_name: "Version #{app_version}", + metadata_path: File.join(project_root, "fastlane/metadata/android"), + track: track, + release_status: release_status, + validate_only: validate_only == "true", + skip_upload_apk: true, + skip_upload_aab: false, + skip_upload_metadata: true, + skip_upload_changelogs: false, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end end diff --git a/fastlane/README.md b/fastlane/README.md index f7d59ab..398140e 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -63,6 +63,14 @@ Build a debug APK Install the debug APK on the connected emulator or device +### android play_release + +```sh +[bundle exec] fastlane android play_release +``` + +Upload a signed release AAB and R8 mapping to Google Play (internal draft by default) + ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. diff --git a/scripts/test-play-release.rb b/scripts/test-play-release.rb new file mode 100644 index 0000000..a79153e --- /dev/null +++ b/scripts/test-play-release.rb @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby +require "json" +require "tmpdir" + +# Exercise the real lane with a recording upload action; never contact Google. +module UI + def self.user_error!(message) = raise(ArgumentError, message) + def self.success(*) = nil + def self.message(*) = nil +end + +class LaneCheck + attr_reader :uploads + def initialize + @lanes = {} + @uploads = [] + path = File.expand_path("../fastlane/Fastfile", __dir__) + instance_eval(File.read(path), path) + end + def default_platform(*) = nil + def opt_out_usage = nil + def ensure_bundle_exec = nil + def desc(*) = nil + def platform(*) = yield + def lane(name, &block) = @lanes[name] = block + def upload_to_play_store(**options) = @uploads << options + def run(**options) = @lanes.fetch(:play_release).call(options) +end + +def assert(value) + raise "Assertion failed" unless value +end + +Dir.mktmpdir("message487-play-test") do |dir| + ENV["MESSAGE487_RELEASE_DIR"] = dir + ENV.delete("SUPPLY_JSON_KEY_DATA") + File.write(File.join(dir, "message487.aab"), "fixture") + File.write(File.join(dir, "mapping.txt"), "fixture") + check = LaneCheck.new + check.run(dry_run: true) + check.run(dry_run: true, validate_only: true) + assert(check.uploads.empty?) + [{dry_run: "yes"}, {validate_only: "yes"}, {track: " "}, + {release_status: "unknown"}, {validate_olny: true}, + {aab: File.join(dir, "missing.aab")}, {mapping: File.join(dir, "message487.aab")}, + {}].each do |options| + begin + check.run(**options) + raise "Expected rejection: #{options}" + rescue ArgumentError + assert(check.uploads.empty?) + end + end + ["invalid-json", "{}", '{"type":"authorized_user"}'].each do |key| + ENV["SUPPLY_JSON_KEY_DATA"] = key + begin + check.run(validate_only: true) + raise "Expected credentials rejection" + rescue ArgumentError + assert(check.uploads.empty?) + end + end + ENV["SUPPLY_JSON_KEY_DATA"] = JSON.generate(type: "service_account", client_email: "test@example.invalid", + private_key: "fixture", token_uri: "https://example.invalid/token") + check.run(validate_only: true) + upload = check.uploads.last + assert(upload.values_at(:package_name, :track, :release_status, :validate_only) == + ["life.andre.message487", "internal", "draft", true]) + assert(upload[:mapping] == File.join(dir, "mapping.txt")) + assert(upload[:skip_upload_metadata] && upload[:skip_upload_images] && upload[:skip_upload_screenshots]) + assert(!upload[:skip_upload_changelogs] && !upload[:skip_upload_aab] && upload[:skip_upload_apk]) + check.run(track: "production", release_status: "completed") + assert(check.uploads.last.values_at(:track, :release_status, :validate_only) == ["production", "completed", false]) +end +puts "play_release checks passed (no network calls)"