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
16 changes: 15 additions & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 <app> --dry-run --notes-en "..." --notes-sr "..." # read + validate only
tool/promote.sh <app> --notes-en "..." --notes-sr "..." # the real thing
```

Takes the version from `apps/<app>/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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app> [--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)
Expand Down
2 changes: 1 addition & 1 deletion apps/bird_tracker/android/deploy.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/ciconia_tracker/android/deploy.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/herp_tracker/android/deploy.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
208 changes: 208 additions & 0 deletions tool/asc_submit.py
Original file line number Diff line number Diff line change
@@ -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 <bundle id> <X.Y.Z> <build number>
[--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()
Loading
Loading