diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index c391007..e7815f8 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Build and deploy (release, publish, upload) the tracker apps — Bird Tracker, Herp Tracker, Ciconia — to Google Play (internal track) and App Store Connect / TestFlight. Use when asked to "build and deploy", "release", "push a new version", "upload to Play / TestFlight" for one or more apps. +description: Build and deploy (release, publish, upload) the tracker apps — Bird Tracker, Herp Tracker, Ciconia — to Google Play (internal track) and App Store Connect / TestFlight, and promote a tested build to production (Play production track, App Store review). Use when asked to "build and deploy", "release", "push a new version", "upload to Play / TestFlight", "promote to prod", "submit for review". --- # Release the tracker apps (Android + iOS) @@ -24,6 +24,20 @@ grep -m1 '^version' pubspec.yaml # the version both stores now car Then commit the two bumped `pubspec.yaml` files: a release builds from the working tree, so whatever is uncommitted at that moment is what shipped — say so if there were uncommitted changes. +## Promote to production (after the user tested the internal / TestFlight build) + +```bash +tool/promote.sh --dry-run --notes-en "..." --notes-sr "..." # read + validate only +tool/promote.sh --notes-en "..." --notes-sr "..." # the real thing +``` + +Takes the version from `apps//pubspec.yaml`, so run it before the next bump. Android (`tool/play_promote.py`): versionCode N moves from internal to production, full rollout unless `--rollout 0.x`; without notes Play carries over the internal release's notes. iOS (`tool/asc_submit.py`): build N becomes App Store version X.Y.Z (a still-editable version is reused and renamed), What's New is set, the version is submitted for review and goes live on approval (`--manual-release` to hold it). `--android` / `--ios` restrict to one store. + +- Always `--dry-run` first and show the output; the real run needs the user's go-ahead and their What's New text — both stores review, but a submission cannot be quietly taken back. +- The App Store has no Serbian: `--notes-sr` goes to a Croatian (`hr`) listing (Herp has one), every other listing (en-GB, …) falls back to `--notes-en`. What's New cannot be edited while the version is in review. +- iOS refuses a new version without What's New (except an app's very first release); the script fails before touching anything. +- The Play service account needs "Release apps to production" (granted Sept 2026); the App Store Connect key needs App Manager or Admin. + ## Preconditions (checked by the scripts, but check first to fail fast) ```bash diff --git a/CLAUDE.md b/CLAUDE.md index fded050..75f98a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ Every app is a complete Flutter project: its own `pubspec.yaml` (name, `X.Y.Z+N` Apple requires a new build number for every App Store Connect upload. +- **Production**: `tool/promote.sh [--dry-run] --notes-en "…" --notes-sr "…"` — once the internal / TestFlight build is tested, moves the version in the app's `pubspec.yaml` to the Play production track (`tool/play_promote.py`, no re-upload) and submits the same build for App Store review (`tool/asc_submit.py`, App Store Connect API with the `ios/deploy.env` key). The whole flow is in the `release` skill. + - **Toolchain**: Flutter is pinned in `.fvmrc` (3.47.5); CI follows `stable`. The Android shells sit on the Flutter template versions (Gradle 9.3.1 / AGP 9.1.0 / Kotlin 2.4.0, `android.builtInKotlin=false` and `android.newDsl=false` in `gradle.properties` as the template sets them). AGP 9 refuses `proguard-android.txt`, so the release build uses `proguard-android-optimize.txt`. Do not bring back the old `subprojects { compileSdk = 34 }` block in `android/build.gradle.kts` — it dragged every plugin down to 34 and broke as soon as one pulled in `androidx.core` 1.18. `tracker_location_permission` no longer applies the Kotlin Gradle plugin (built-in Kotlin); `location` still does, which is the one KGP warning left and not ours to fix. iOS runs the **UIScene** lifecycle: `AppDelegate` adopts `FlutterImplicitEngineDelegate` and registers the plugins in `didInitializeImplicitFlutterEngine`, `Info.plist` carries `UIApplicationSceneManifest` — the Maps key must still be handed to `GMSServices` before `super.application(...)`. `google_maps_flutter_ios_sdk9` adopts Swift Package Manager for iOS 15+. The gen_snapshot "unobfuscated DWARF" warning on Android is expected: the tool leaves stripping to AGP. ## Configuration / secrets (not in git, per app) diff --git a/apps/bird_tracker/android/deploy.env.example b/apps/bird_tracker/android/deploy.env.example index 55f6d80..041e873 100644 --- a/apps/bird_tracker/android/deploy.env.example +++ b/apps/bird_tracker/android/deploy.env.example @@ -5,7 +5,7 @@ # 1) Google Cloud Console → APIs & Services → uključi "Google Play Android Developer API" # 2) IAM & Admin → Service Accounts → Create → Keys → Add key → JSON # 3) Play Console → Users and permissions → Invite user (email servisnog naloga) -# → dodeli aplikacije + dozvolu "Release to testing tracks" +# → dodeli aplikacije + dozvolu "Release to testing tracks" (+ "Release apps to production" za tool/promote.sh) # Isti ključ služi za sve tracker aplikacije. PLAY_SERVICE_ACCOUNT_JSON=/Users/ivanantonijevic/keys/play-service-account.json diff --git a/apps/ciconia_tracker/android/deploy.env.example b/apps/ciconia_tracker/android/deploy.env.example index 55f6d80..041e873 100644 --- a/apps/ciconia_tracker/android/deploy.env.example +++ b/apps/ciconia_tracker/android/deploy.env.example @@ -5,7 +5,7 @@ # 1) Google Cloud Console → APIs & Services → uključi "Google Play Android Developer API" # 2) IAM & Admin → Service Accounts → Create → Keys → Add key → JSON # 3) Play Console → Users and permissions → Invite user (email servisnog naloga) -# → dodeli aplikacije + dozvolu "Release to testing tracks" +# → dodeli aplikacije + dozvolu "Release to testing tracks" (+ "Release apps to production" za tool/promote.sh) # Isti ključ služi za sve tracker aplikacije. PLAY_SERVICE_ACCOUNT_JSON=/Users/ivanantonijevic/keys/play-service-account.json diff --git a/apps/herp_tracker/android/deploy.env.example b/apps/herp_tracker/android/deploy.env.example index 55f6d80..041e873 100644 --- a/apps/herp_tracker/android/deploy.env.example +++ b/apps/herp_tracker/android/deploy.env.example @@ -5,7 +5,7 @@ # 1) Google Cloud Console → APIs & Services → uključi "Google Play Android Developer API" # 2) IAM & Admin → Service Accounts → Create → Keys → Add key → JSON # 3) Play Console → Users and permissions → Invite user (email servisnog naloga) -# → dodeli aplikacije + dozvolu "Release to testing tracks" +# → dodeli aplikacije + dozvolu "Release to testing tracks" (+ "Release apps to production" za tool/promote.sh) # Isti ključ služi za sve tracker aplikacije. PLAY_SERVICE_ACCOUNT_JSON=/Users/ivanantonijevic/keys/play-service-account.json diff --git a/tool/asc_submit.py b/tool/asc_submit.py new file mode 100755 index 0000000..0751937 --- /dev/null +++ b/tool/asc_submit.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Submit a build that is already in App Store Connect for App Store review. + +The App Store counterpart of play_promote.py: the build deploy_ios.sh uploaded +(the one tested in TestFlight) is attached to an App Store version and sent to +review — through the App Store Connect API, with the same API key. + + asc_submit.py + [--notes en-US="..." --notes sr="..."] [--manual-release] [--dry-run] + +Steps: find the build and check Apple finished processing it; reuse the App +Store version that is still editable (renamed to X.Y.Z) or create it; attach +the build; set "What's New" on every localization of the version; submit. +--notes keys are App Store locales (en-US, de-DE, ...); a localization with no +note of its own gets en-US. Without --notes the existing text is kept, which +Apple refuses to submit empty for an update. The version goes live as soon as +it is approved, or waits for "Release" in App Store Connect with --manual-release. +--dry-run only reads and prints the plan. + +Credentials from the app's ios/deploy.env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH +(the key needs the App Manager or Admin role). + +Needs: pip3 install pyjwt cryptography requests +""" + +import argparse +import os +import sys +import time + +import jwt +import requests + +API = "https://api.appstoreconnect.apple.com/v1" + +# a version in one of these can still be edited and submitted +EDITABLE = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", + "METADATA_REJECTED", "INVALID_BINARY"} + + +def fail(message): + print(f"asc_submit: {message}", file=sys.stderr) + sys.exit(1) + + +class Api: + def __init__(self, key_id, issuer, key_path): + with open(key_path) as f: + key = f.read() + now = int(time.time()) + token = jwt.encode({"iss": issuer, "iat": now, "exp": now + 15 * 60, "aud": "appstoreconnect-v1"}, + key, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}) + self.session = requests.Session() + self.session.headers.update({"Authorization": f"Bearer {token}", "Content-Type": "application/json"}) + + def call(self, method, path, **kwargs): + response = self.session.request(method, f"{API}{path}", timeout=60, **kwargs) + if response.status_code >= 400: + try: + errors = response.json()["errors"] + detail = "; ".join(f"{e.get('title')}: {e.get('detail')}" for e in errors) + except (ValueError, KeyError): + detail = response.text[:500] + fail(f"{method} {path} → {response.status_code}: {detail}") + return response.json() if response.content else {} + + def get(self, path, **params): + return self.call("GET", path, params=params) + + def post(self, path, data): + return self.call("POST", path, json={"data": data})["data"] + + def patch(self, path, data): + return self.call("PATCH", path, json={"data": data}) + + +def parse_notes(pairs): + notes = {} + for pair in pairs or []: + locale, sep, text = pair.partition("=") + if not sep or not locale or not text: + fail(f"--notes expects LOCALE=TEXT, got: {pair}") + notes[locale] = text + return notes + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("bundle_id", help="e.g. rs.antonijevic.birdTracker") + parser.add_argument("version", help="App Store version string, X.Y.Z") + parser.add_argument("build", help="build number (the +N of pubspec)") + parser.add_argument("--notes", action="append", metavar="LOCALE=TEXT", help="What's New, repeatable") + parser.add_argument("--manual-release", action="store_true", help="wait for a manual release after approval") + parser.add_argument("--dry-run", action="store_true", help="read only, print the plan") + args = parser.parse_args() + + for var in ("ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_PATH"): + if not os.environ.get(var): + fail(f"{var} is not set (source the app's ios/deploy.env)") + if not os.path.isfile(os.environ["ASC_KEY_PATH"]): + fail(f"API key not found: {os.environ['ASC_KEY_PATH']}") + notes = parse_notes(args.notes) + api = Api(os.environ["ASC_KEY_ID"], os.environ["ASC_ISSUER_ID"], os.environ["ASC_KEY_PATH"]) + dry = "(dry run) " if args.dry_run else "" + + apps = api.get("/apps", **{"filter[bundleId]": args.bundle_id})["data"] + if not apps: + fail(f"no app with bundle id {args.bundle_id} in App Store Connect") + app = apps[0] + print(f"==> {app['attributes']['name']} ({args.bundle_id})") + + builds = api.get("/builds", **{"filter[app]": app["id"], "filter[version]": args.build, + "filter[preReleaseVersion.version]": args.version})["data"] + if not builds: + fail(f"build {args.version} ({args.build}) is not in App Store Connect — was it uploaded?") + build = builds[0] + state = build["attributes"]["processingState"] + expired = build["attributes"].get("expired") + if state != "VALID" or expired: + fail(f"build {args.version} ({args.build}) is {state}{' and expired' if expired else ''}" + " — wait until Apple finishes processing it") + print(f" build {args.version} ({args.build}): processed") + + versions = api.get(f"/apps/{app['id']}/appStoreVersions", **{"filter[platform]": "IOS", "limit": 20})["data"] + same = next((v for v in versions if v["attributes"]["versionString"] == args.version), None) + if same and same["attributes"]["appStoreState"] not in EDITABLE: + fail(f"version {args.version} is already {same['attributes']['appStoreState']} — nothing to submit") + editable = same or next((v for v in versions if v["attributes"]["appStoreState"] in EDITABLE), None) + first_release = not any(v["attributes"]["appStoreState"] == "READY_FOR_SALE" for v in versions) + release_type = "MANUAL" if args.manual_release else "AFTER_APPROVAL" + + # a new version starts with an empty What's New, which Apple refuses for an + # update — fail here, before anything in App Store Connect has changed + if editable is None and not first_release and not notes: + fail(f"version {args.version} is new and needs What's New: pass --notes en-US=...") + + if editable is None: + print(f" {dry}create version {args.version}") + version = None if args.dry_run else api.post("/appStoreVersions", { + "type": "appStoreVersions", + "attributes": {"platform": "IOS", "versionString": args.version, "releaseType": release_type}, + "relationships": {"app": {"data": {"type": "apps", "id": app["id"]}}}, + }) + else: + old = editable["attributes"]["versionString"] + rename = "" if old == args.version else f", renamed from {old}" + print(f" {dry}reuse version {args.version} ({editable['attributes']['appStoreState']}{rename})") + version = editable + if not args.dry_run: + api.patch(f"/appStoreVersions/{version['id']}", { + "type": "appStoreVersions", "id": version["id"], + "attributes": {"versionString": args.version, "releaseType": release_type}, + }) + + print(f" {dry}attach build {args.build}; release {'manually' if args.manual_release else 'on approval'}") + if version is not None and not args.dry_run: + api.patch(f"/appStoreVersions/{version['id']}/relationships/build", + {"type": "builds", "id": build["id"]}) + + # What's New: not allowed on an app's first version, required on every later one + if first_release: + print(" first release of the app: no What's New") + elif version is None: + print(f" {dry}What's New: {', '.join(notes) or 'none given — Apple refuses an update without it'}") + else: + localizations = api.get(f"/appStoreVersions/{version['id']}/appStoreVersionLocalizations")["data"] + for loc in localizations: + locale = loc["attributes"]["locale"] + text = notes.get(locale) or notes.get(locale.split("-")[0]) or notes.get("en-US") or notes.get("en") + if text: + print(f" {dry}What's New [{locale}]: {text[:60]}{'…' if len(text) > 60 else ''}") + if not args.dry_run: + api.patch(f"/appStoreVersionLocalizations/{loc['id']}", { + "type": "appStoreVersionLocalizations", "id": loc["id"], + "attributes": {"whatsNew": text}, + }) + elif loc["attributes"].get("whatsNew"): + print(f" What's New [{locale}]: kept") + else: + fail(f"What's New is empty for {locale}: pass --notes {locale}=... (or en-US=...)") + + if args.dry_run: + print("✓ dry run: nothing was changed; without --dry-run the version is submitted for review") + return + + existing = api.get("/reviewSubmissions", **{"filter[app]": app["id"], "filter[platform]": "IOS", + "filter[state]": "READY_FOR_REVIEW"})["data"] + submission = existing[0] if existing else api.post("/reviewSubmissions", { + "type": "reviewSubmissions", + "attributes": {"platform": "IOS"}, + "relationships": {"app": {"data": {"type": "apps", "id": app["id"]}}}, + }) + api.post("/reviewSubmissionItems", { + "type": "reviewSubmissionItems", + "relationships": { + "reviewSubmission": {"data": {"type": "reviewSubmissions", "id": submission["id"]}}, + "appStoreVersion": {"data": {"type": "appStoreVersions", "id": version["id"]}}, + }, + }) + api.patch(f"/reviewSubmissions/{submission['id']}", { + "type": "reviewSubmissions", "id": submission["id"], "attributes": {"submitted": True}, + }) + print(f"✓ {args.version} ({args.build}) submitted for App Store review; " + f"{'release it by hand once approved' if args.manual_release else 'it goes live once approved'}") + + +if __name__ == "__main__": + main() diff --git a/tool/play_promote.py b/tool/play_promote.py new file mode 100755 index 0000000..491878b --- /dev/null +++ b/tool/play_promote.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Promote a release that is already on a Google Play track to another track. + +The production counterpart of play_upload.py: nothing is uploaded, the version +code that tested fine on the internal track is put on production as is — one +Play Developer API edit: open, read the source track, update the target, commit. + + play_promote.py [--from internal] [--to production] + [--rollout 0.2] [--notes en-US="..." --notes sr="..."] + [--key sa.json] [--dry-run] + +--rollout below 1 is a staged rollout (status inProgress); omitted, the release +goes to everyone. --notes sets "What's new" per Play language code; without it +the notes the release already has on the source track are carried over. +--dry-run opens the edit, applies it, has Play validate it and throws it away. + +The service account needs "Release apps to production" in Play Console → +Users and permissions, on top of the testing-track permission play_upload.py +needs. Production changes go to Google's review before they are live. + +Needs: pip3 install google-api-python-client google-auth +""" + +import argparse +import json +import os +import sys + +from google.oauth2 import service_account +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +SCOPES = ["https://www.googleapis.com/auth/androidpublisher"] + + +def fail(message): + print(f"play_promote: {message}", file=sys.stderr) + sys.exit(1) + + +def api_message(error): + try: + return json.loads(error.content)["error"]["message"] + except (ValueError, KeyError, TypeError): + return str(error) + + +def parse_notes(pairs): + notes = [] + for pair in pairs or []: + language, sep, text = pair.partition("=") + if not sep or not language or not text: + fail(f"--notes expects LANG=TEXT, got: {pair}") + notes.append({"language": language, "text": text}) + return notes + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("package", help="applicationId, e.g. rs.antonijevic.bird_tracker") + parser.add_argument("version_code", help="the versionCode to promote, e.g. 34") + parser.add_argument("--from", dest="source", default="internal", help="source track (default: internal)") + parser.add_argument("--to", dest="target", default="production", help="target track (default: production)") + parser.add_argument("--rollout", type=float, help="staged rollout fraction, 0 < f < 1") + parser.add_argument("--notes", action="append", metavar="LANG=TEXT", help="release notes, repeatable") + parser.add_argument("--key", default=os.environ.get("PLAY_SERVICE_ACCOUNT_JSON"), + help="service account JSON key (default: $PLAY_SERVICE_ACCOUNT_JSON)") + parser.add_argument("--dry-run", action="store_true", help="validate the edit, then discard it") + args = parser.parse_args() + + if not args.key or not os.path.isfile(args.key): + fail(f"service account key not found: {args.key}") + if args.rollout is not None and not 0 < args.rollout < 1: + fail("--rollout must be between 0 and 1 (omit it for a full release)") + notes = parse_notes(args.notes) + code = str(args.version_code) + package = args.package + + credentials = service_account.Credentials.from_service_account_file(args.key, scopes=SCOPES) + edits = build("androidpublisher", "v3", credentials=credentials, cache_discovery=False).edits() + + try: + edit_id = edits.insert(packageName=package, body={}).execute()["id"] + try: + source = edits.tracks().get(packageName=package, editId=edit_id, track=args.source).execute() + release = next((r for r in source.get("releases", []) if code in r.get("versionCodes", [])), None) + if release is None: + on_track = [c for r in source.get("releases", []) for c in r.get("versionCodes", [])] + fail(f"versionCode {code} is not on the '{args.source}' track of {package} " + f"(it has: {', '.join(on_track) or 'nothing'})") + + target_release = {"versionCodes": [code], "status": "completed"} + if release.get("name"): + target_release["name"] = release["name"] + if args.rollout is not None: + target_release.update(status="inProgress", userFraction=args.rollout) + if notes or release.get("releaseNotes"): + target_release["releaseNotes"] = notes or release["releaseNotes"] + + edits.tracks().update( + packageName=package, editId=edit_id, track=args.target, + body={"track": args.target, "releases": [target_release]}, + ).execute() + + languages = ", ".join(n["language"] for n in target_release.get("releaseNotes", [])) or "none" + share = f"{args.rollout:.0%} staged rollout" if args.rollout is not None else "full rollout" + summary = f"versionCode {code} → '{args.target}' of {package} ({share}; release notes: {languages})" + + if args.dry_run: + edits.validate(packageName=package, editId=edit_id).execute() + print(f"✓ dry run, Play accepted: {summary}") + return + edits.commit(packageName=package, editId=edit_id).execute() + edit_id = None + print(f"✓ {summary}") + print(" Google reviews production changes; the release goes live once approved.") + finally: + if edit_id is not None: + edits.delete(packageName=package, editId=edit_id).execute() + except HttpError as error: + fail(f"Play API {error.resp.status}: {api_message(error)}") + + +if __name__ == "__main__": + main() diff --git a/tool/promote.sh b/tool/promote.sh new file mode 100755 index 0000000..9ab00c1 --- /dev/null +++ b/tool/promote.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# +# promote.sh — put the tested release of one app into production on both stores. +# +# tool/promote.sh --dry-run # check both stores, change nothing +# tool/promote.sh --notes-en "..." --notes-sr "..." +# +# bird_tracker | herp_tracker | ciconia_tracker +# --android | --ios only one store (default: both) +# --rollout F Play staged rollout, 0 < F < 1 (default: everyone) +# --manual-release App Store: wait for "Release" after approval (default: live on approval) +# --notes-en / --notes-sr What's New; Play en-US / sr, App Store en-US / hr +# +# The version is the one in apps//pubspec.yaml (X.Y.Z+N) — the one the last +# build_release.sh --bump / deploy_ios.sh --no-bump pair uploaded. Android: the +# versionCode N moves from the internal track to production (tool/play_promote.py). +# iOS: build N becomes App Store version X.Y.Z and is submitted for review +# (tool/asc_submit.py). Both stores review before the release is live. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="${1:-}" +[[ -n "$APP" && "$APP" != -* ]] || { sed -n '5,13p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 64; } +shift +DIR="$ROOT/apps/$APP" +[[ -d "$DIR" ]] || { echo "No app at $DIR" >&2; exit 1; } + +ANDROID=true; IOS=true; DRY=(); ROLLOUT=(); MANUAL=(); NOTES_EN=""; NOTES_SR="" +while [[ $# -gt 0 ]]; do + case "$1" in + --android) IOS=false ;; + --ios) ANDROID=false ;; + --dry-run) DRY=(--dry-run) ;; + --rollout) ROLLOUT=(--rollout "$2"); shift ;; + --manual-release) MANUAL=(--manual-release) ;; + --notes-en) NOTES_EN="$2"; shift ;; + --notes-sr) NOTES_SR="$2"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 64 ;; + esac + shift +done + +version="$(grep -m1 -E '^version:' "$DIR/pubspec.yaml" | sed -E 's/^version:[[:space:]]*//' | tr -d '[:space:]')" +name="${version%%+*}"; code="${version#*+}" +[[ "$code" != "$version" ]] || { echo "No +build in version '$version'" >&2; exit 1; } +echo "== $APP $name ($code)" + +if $ANDROID; then + package="$(grep -m1 -oE 'applicationId = "[^"]+"' "$DIR/android/app/build.gradle.kts" | cut -d'"' -f2)" + # shellcheck disable=SC1091 + source "$DIR/android/deploy.env" + notes=() + [[ -n "$NOTES_EN" ]] && notes+=(--notes "en-US=$NOTES_EN") + [[ -n "$NOTES_SR" ]] && notes+=(--notes "sr=$NOTES_SR") + echo "-- Google Play" + PLAY_SERVICE_ACCOUNT_JSON="$PLAY_SERVICE_ACCOUNT_JSON" python3 "$ROOT/tool/play_promote.py" \ + "$package" "$code" ${ROLLOUT[@]+"${ROLLOUT[@]}"} ${notes[@]+"${notes[@]}"} ${DRY[@]+"${DRY[@]}"} +fi + +if $IOS; then + bundle="$(grep -m1 -oE 'PRODUCT_BUNDLE_IDENTIFIER = rs\.antonijevic\.[A-Za-z]+;' \ + "$DIR/ios/Runner.xcodeproj/project.pbxproj" | sed -E 's/.* = (.*);/\1/')" + # shellcheck disable=SC1091 + source "$DIR/ios/deploy.env" + notes=() + [[ -n "$NOTES_EN" ]] && notes+=(--notes "en-US=$NOTES_EN") + # the App Store has no Serbian; a listing in Croatian stands in for it + [[ -n "$NOTES_SR" ]] && notes+=(--notes "hr=$NOTES_SR") + echo "-- App Store" + ASC_KEY_ID="$ASC_KEY_ID" ASC_ISSUER_ID="$ASC_ISSUER_ID" ASC_KEY_PATH="$ASC_KEY_PATH" \ + python3 "$ROOT/tool/asc_submit.py" "$bundle" "$name" "$code" \ + ${MANUAL[@]+"${MANUAL[@]}"} ${notes[@]+"${notes[@]}"} ${DRY[@]+"${DRY[@]}"} +fi