Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
53 changes: 50 additions & 3 deletions docs/en/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ flag, and verifies the certificate and signature of every AAB payload entry.
| --- | --- |
| `message487-<version>.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 |

Expand Down Expand Up @@ -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/<locale>/changelogs/<versionCode>.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.

Expand Down
56 changes: 53 additions & 3 deletions docs/ru/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ APK, а также сертификат и подпись каждого сод
| --- | --- |
| `message487-<version>.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 |

Expand Down Expand Up @@ -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/<locale>/changelogs/<versionCode>.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.
Expand Down
73 changes: 73 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions fastlane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions scripts/test-play-release.rb
Original file line number Diff line number Diff line change
@@ -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)"
Loading