From e65f5ec50aacbf212e2577f0a25d80ae09220f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:00:07 +0000 Subject: [PATCH] feat(release): publish fastlane changelogs and screenshots to Play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fastforge publish --targets playstore` only uploads the bundle and names the track release — it never calls the Play API for release notes or the store listing, so the changelogs `changelog.yml` has been generating into `fastlane/metadata/android/en-US/changelogs/` only ever reached F-Droid, and the screenshots never left the repository. Replace it with `fastlane supply`, which uploads the bundle and the whole metadata tree in a single Play edit, so the listing cannot drift from the binary it ships with. It reads the same fastlane layout that is already here, and picks the changelog matching the uploaded version code, which is the scheme `version_code.txt` already produces. The tree cannot be handed to Play unchanged: release notes are capped at 500 characters (three committed ones are over), screenshots may not carry an alpha channel, and their longest side may be at most twice the shortest — the phone screenshots are 1080x2424, or 1:2.24. Rather than degrade the F-Droid assets, `tool/prepare_play_metadata.py` writes a normalised copy that only Play sees: notes trimmed on a bullet boundary, screenshots flattened to RGB and letterboxed by repeating their edge pixels, so the padding continues the app bar instead of adding black bars. Anything hand-written that Play would reject, such as a title over 30 characters, fails the job instead of being silently fixed. Keeping the committed screenshots untouched also leaves `tool/build_site.sh`, which copies them onto the landing page, unaffected. Add `de-DE/changelogs/default.txt` so the German listing gets release notes rather than an empty "What's new"; the generated changelog is English only. --- .github/workflows/release.yml | 51 ++-- README.md | 57 +++- .../android/de-DE/changelogs/default.txt | 4 + tool/prepare_play_metadata.py | 281 ++++++++++++++++++ 4 files changed, 371 insertions(+), 22 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/default.txt create mode 100755 tool/prepare_play_metadata.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44ecc17..ab534f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,31 +312,46 @@ jobs: run: | echo "${{ secrets.PLAYSTORE_CREDENTIALS_BASE64 }}" | base64 --decode > "${{ runner.temp }}/playstore-credentials.json" - - name: Set up Java - uses: actions/setup-java@v5 + # `fastforge publish --targets playstore` only uploads the bundle and + # names the track release; it never sends release notes or the store + # listing, which is why the fastlane changelogs never reached Play. + # `fastlane supply` uploads the bundle *and* the metadata tree in one + # Play edit, so the listing can never drift from the binary it ships. + - name: Set up Ruby + uses: ruby/setup-ruby@v1 with: - distribution: temurin - java-version: '17' + ruby-version: '3.3' - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.41.7' - cache: true + - name: Install fastlane + run: gem install fastlane -N -v 2.238.0 - - name: Install Fastforge - run: dart pub global activate fastforge + - name: Install Pillow + run: pip install --disable-pip-version-check pillow + + # The committed tree targets F-Droid, which has none of Play's limits on + # release-note length or screenshot aspect ratio. See the script. + - name: Prepare Play Store metadata + run: | + python3 tool/prepare_play_metadata.py \ + fastlane/metadata/android \ + "${{ runner.temp }}/play-metadata" - name: Publish to Play Store - env: - PLAYSTORE_CREDENTIALS: ${{ runner.temp }}/playstore-credentials.json run: | + set -e AAB_PATH=$(find artifacts/aab -name "*.aab" | head -1) - fastforge publish \ - --path "$AAB_PATH" \ - --targets playstore \ - --playstore-package-name org.openpatch.classi \ - --playstore-track production + if [ -z "$AAB_PATH" ]; then + echo "ERROR: no .aab found in artifacts/aab" >&2 + exit 1 + fi + fastlane supply \ + --package_name org.openpatch.classi \ + --aab "$AAB_PATH" \ + --track production \ + --release_status completed \ + --json_key "${{ runner.temp }}/playstore-credentials.json" \ + --metadata_path "${{ runner.temp }}/play-metadata" \ + --sync_image_upload true release: name: Create GitHub Release diff --git a/README.md b/README.md index 061aafc..5262390 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,49 @@ Or run all platforms at once using the project release config: fastforge release --name release ``` +## Store metadata + +`fastlane/metadata/android/` holds the store listing — title, descriptions, +icon, phone screenshots and one release-notes file per version code +(`en-US/changelogs/46.txt` belongs to the build whose `version_code.txt` said +`46`). F-Droid reads this tree straight from the repository; the Play Store +gets it from the `release.yml` workflow via `fastlane supply`. + +The tree is authored for F-Droid, which accepts anything. Google Play does not: +release notes are capped at 500 characters, screenshots may not have an alpha +channel, and their longest side may be at most twice the shortest — the +1080x2424 phone screenshots here are 1:2.24 and would be rejected. Rather than +degrade the F-Droid assets, `tool/prepare_play_metadata.py` writes a normalised +copy that only Play sees: + +```bash +python3 tool/prepare_play_metadata.py fastlane/metadata/android build/play-metadata +``` + +It trims release notes on a bullet boundary, drops the alpha channel and +letterboxes screenshots by repeating their edge pixels (so padding continues +the app bar instead of adding black bars), and fails loudly on anything +hand-written that Play would reject, such as a title over 30 characters. + +Because the whole tree is uploaded on every release, the repository is the +source of truth: edits made directly in the Play Console are overwritten on the +next tag. To see what would be sent without committing anything to Play, add +`--validate_only true`: + +```bash +fastlane supply --package_name org.openpatch.classi \ + --metadata_path build/play-metadata \ + --skip_upload_aab true --skip_upload_apk true --skip_upload_changelogs true \ + --json_key playstore-credentials.json --validate_only true +``` + +(Release notes are skipped there because they attach to a release that only +exists once the bundle is uploaded.) + +German release notes fall back to `de-DE/changelogs/default.txt`, since the +generated changelog is English only. Add `de-DE/changelogs/.txt` +to give a release proper German notes. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, pull request @@ -168,7 +211,7 @@ expectations, and release hygiene. ## GitHub Actions -Four workflows are included: +Five workflows are included: - `ci.yml` — runs on every push to `main`/`master` and on pull requests. It installs dependencies, runs Drift code generation, analyzes the code, and @@ -192,11 +235,17 @@ Four workflows are included: Android PR builds use the application ID `org.openpatch.classi.pr` so they can be installed alongside the production app without overwriting it. +- `changelog.yml` — on every push to `main`, regenerates the unreleased section + with `git-cliff` into `fastlane/metadata/android/en-US/changelogs/.txt` and commits it, so the next release already carries its + notes for both stores. See [Store metadata](#store-metadata). - `release.yml` — triggered by version tags (`v*`). It generates a changelog with `git-cliff`, commits an updated `CHANGELOG.md`, builds release artifacts - for Android (APK), Linux (AppImage), macOS (DMG), and Windows (EXE installer) - using [Fastforge](https://fastforge.dev/), and publishes a GitHub Release with - all artifacts attached. + for Android (APK and AAB), Linux (AppImage), macOS (DMG), and Windows (EXE + installer) using [Fastforge](https://fastforge.dev/), publishes a GitHub + Release with all artifacts attached, and uploads the AAB to the Play Store + production track together with the + [store metadata](#store-metadata) using `fastlane supply`. ## Development support diff --git a/fastlane/metadata/android/de-DE/changelogs/default.txt b/fastlane/metadata/android/de-DE/changelogs/default.txt new file mode 100644 index 0000000..79a0b03 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/default.txt @@ -0,0 +1,4 @@ +Fehlerbehebungen und Verbesserungen. + +Die vollständigen Änderungen dieser Version stehen unter +https://github.com/openpatch/classi/releases diff --git a/tool/prepare_play_metadata.py b/tool/prepare_play_metadata.py new file mode 100755 index 0000000..7172bdf --- /dev/null +++ b/tool/prepare_play_metadata.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Build a Google-Play-ready copy of the fastlane metadata tree. + +`fastlane/metadata/android` is authored for F-Droid, which accepts whatever it +is given. Google Play does not: it rejects release notes over 500 characters, +screenshots whose longest side is more than twice the shortest, and screenshots +carrying an alpha channel. The phone screenshots in this repo are 1080x2424 +(1:2.24), so handing the tree to `fastlane supply` unchanged fails the upload. + +Rather than degrade the F-Droid assets to Play's lowest common denominator, +this writes a normalised copy that `supply --metadata_path` consumes: + + * release notes are trimmed to 500 characters on a line/word boundary, + * screenshots are flattened to RGB and letterboxed (never cropped) by + repeating their edge pixels until the aspect ratio is within Play's 2:1 + limit, + * oversized screenshots are scaled down under Play's 3840 px ceiling. + +Anything hand-written that Play would reject is an error rather than a silent +fix, because a truncated store title or a dropped screenshot should be a human +decision. + +Usage: + tool/prepare_play_metadata.py SOURCE_DIR OUTPUT_DIR + +SOURCE_DIR and OUTPUT_DIR are both the directory that holds the locale folders, +i.e. `fastlane/metadata/android`. +""" + +from __future__ import annotations + +import argparse +import math +import shutil +import sys +from pathlib import Path + +from PIL import Image + +# https://support.google.com/googleplay/android-developer/answer/9859455 +MAX_TITLE = 30 +MAX_SHORT_DESCRIPTION = 80 +MAX_FULL_DESCRIPTION = 4000 +MAX_RELEASE_NOTES = 500 + +# https://developers.google.com/android-publisher/api-ref/rest/v3/AppImageType +MIN_SIDE = 320 +MAX_SIDE = 3840 +MAX_RATIO = 2.0 +MAX_SCREENSHOTS_PER_TYPE = 8 + +TEXT_LIMITS = { + "title": MAX_TITLE, + "short_description": MAX_SHORT_DESCRIPTION, + "full_description": MAX_FULL_DESCRIPTION, +} +# `video` holds a URL, so it has no length limit worth enforcing here. +TEXT_FILES = list(TEXT_LIMITS) + ["video"] + +IMAGE_TYPES = { + "featureGraphic": (1024, 500), + "icon": (512, 512), + "tvBanner": (1280, 720), +} +SCREENSHOT_TYPES = [ + "phoneScreenshots", + "sevenInchScreenshots", + "tenInchScreenshots", + "tvScreenshots", + "wearScreenshots", +] +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg"} + +warnings: list[str] = [] +errors: list[str] = [] + + +def trim_release_notes(text: str) -> str: + """Cuts release notes to Play's 500-character limit at a readable boundary. + + git-cliff writes one bullet per commit, so a busy release overshoots easily. + Cutting at the last newline keeps whole bullets; a section header left with + no bullets under it is then dropped, since a trailing "Features" reads as a + rendering bug rather than as a truncated list. + """ + text = text.strip() + if len(text) <= MAX_RELEASE_NOTES: + return text + + head = text[:MAX_RELEASE_NOTES] + for boundary in ("\n", " "): + cut = head.rfind(boundary) + # Refuse a boundary that throws away most of the budget; a hard cut of + # 500 readable characters beats 40 tidy ones. + if cut > MAX_RELEASE_NOTES // 2: + head = head[:cut] + break + + lines = head.rstrip().splitlines() + while lines and not lines[-1].startswith("- "): + lines.pop() + return "\n".join(lines).rstrip() or head.rstrip() + + +def letterbox(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """Centres the image on a larger canvas, filling the margins by stretching + the outermost row/column of pixels outwards. + + A flat fill colour would put black bars beside a coloured app bar. Repeating + the edge pixels instead continues whatever is already there, so the padding + reads as part of the screenshot rather than as a border around it. + """ + width, height = image.size + target_width, target_height = size + canvas = Image.new("RGB", size) + left, top = (target_width - width) // 2, (target_height - height) // 2 + canvas.paste(image, (left, top)) + + if left > 0: + right = target_width - width - left + canvas.paste(image.crop((0, 0, 1, height)).resize((left, height)), (0, top)) + canvas.paste( + image.crop((width - 1, 0, width, height)).resize((right, height)), + (left + width, top), + ) + + # Read the rows back off the canvas rather than off the image, so that a + # canvas needing both directions gets corners filled instead of left black. + if top > 0: + bottom = target_height - height - top + first = canvas.crop((0, top, target_width, top + 1)) + last = canvas.crop((0, top + height - 1, target_width, top + height)) + canvas.paste(first.resize((target_width, top)), (0, 0)) + canvas.paste(last.resize((target_width, bottom)), (0, top + height)) + + return canvas + + +def normalise_screenshot(source: Path, destination: Path) -> None: + with Image.open(source) as opened: + image = opened.convert("RGB") # Play rejects an alpha channel. + + width, height = image.size + if min(width, height) < MIN_SIDE: + errors.append( + f"{source}: {width}x{height} is below Play's {MIN_SIDE} px minimum side" + ) + return + + if max(width, height) > MAX_SIDE: + scale = MAX_SIDE / max(width, height) + width, height = round(width * scale), round(height * scale) + image = image.resize((width, height), Image.LANCZOS) + warnings.append(f"{source}: scaled down to {width}x{height}") + + # Letterbox instead of cropping: losing a row of the screenshot is a + # worse outcome than a strip of the app's own background colour. + target_width = max(width, math.ceil(height / MAX_RATIO)) + target_height = max(height, math.ceil(width / MAX_RATIO)) + if (target_width, target_height) != (width, height): + image = letterbox(image, (target_width, target_height)) + warnings.append( + f"{source}: padded {width}x{height} to {target_width}x{target_height} " + f"for Play's {MAX_RATIO:g}:1 aspect ratio limit" + ) + + destination.parent.mkdir(parents=True, exist_ok=True) + image.save(destination, "PNG") + + +def copy_text_files(locale_dir: Path, out_locale: Path) -> None: + for name in TEXT_FILES: + source = locale_dir / f"{name}.txt" + if not source.exists(): + continue + text = source.read_text(encoding="utf-8").strip() + limit = TEXT_LIMITS.get(name) + if limit and len(text) > limit: + errors.append( + f"{source}: {len(text)} characters exceeds Play's {limit}-character limit" + ) + continue + (out_locale / f"{name}.txt").write_text(text + "\n", encoding="utf-8") + + +def copy_changelogs(locale_dir: Path, out_locale: Path) -> None: + source_dir = locale_dir / "changelogs" + if not source_dir.is_dir(): + return + out_dir = out_locale / "changelogs" + out_dir.mkdir(parents=True, exist_ok=True) + for source in sorted(source_dir.glob("*.txt")): + text = source.read_text(encoding="utf-8") + trimmed = trim_release_notes(text) + if len(trimmed) < len(text.strip()): + warnings.append( + f"{source}: trimmed from {len(text.strip())} to {len(trimmed)} characters" + ) + (out_dir / source.name).write_text(trimmed + "\n", encoding="utf-8") + + +def copy_images(locale_dir: Path, out_locale: Path) -> None: + source_dir = locale_dir / "images" + if not source_dir.is_dir(): + return + + for image_type, expected_size in IMAGE_TYPES.items(): + matches = [ + path + for path in sorted(source_dir.iterdir()) + if path.stem == image_type and path.suffix.lower() in IMAGE_SUFFIXES + ] + for source in matches: + with Image.open(source) as image: + if image.size != expected_size: + errors.append( + f"{source}: {image.size[0]}x{image.size[1]} but Play requires " + f"{expected_size[0]}x{expected_size[1]} for {image_type}" + ) + continue + destination = out_locale / "images" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + for screenshot_type in SCREENSHOT_TYPES: + type_dir = source_dir / screenshot_type + if not type_dir.is_dir(): + continue + sources = sorted( + path for path in type_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES + ) + if len(sources) > MAX_SCREENSHOTS_PER_TYPE: + errors.append( + f"{type_dir}: {len(sources)} screenshots but Play accepts at most " + f"{MAX_SCREENSHOTS_PER_TYPE}" + ) + continue + for source in sources: + out_name = source.with_suffix(".png").name + normalise_screenshot(source, out_locale / "images" / screenshot_type / out_name) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help="fastlane/metadata/android") + parser.add_argument("output", type=Path, help="where to write the Play-ready copy") + args = parser.parse_args() + + if not args.source.is_dir(): + print(f"ERROR: {args.source} is not a directory", file=sys.stderr) + return 1 + + if args.output.exists(): + shutil.rmtree(args.output) + args.output.mkdir(parents=True) + + locales = sorted( + path for path in args.source.iterdir() if path.is_dir() and not path.name.startswith(".") + ) + if not locales: + print(f"ERROR: no locale directories found in {args.source}", file=sys.stderr) + return 1 + + for locale_dir in locales: + out_locale = args.output / locale_dir.name + out_locale.mkdir(parents=True, exist_ok=True) + copy_text_files(locale_dir, out_locale) + copy_changelogs(locale_dir, out_locale) + copy_images(locale_dir, out_locale) + print(f"Prepared {locale_dir.name}") + + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main())