App Store Connect API: Veröffentlichung automatisieren
9 Min. Lesezeit

Eine App für den App Store vorzubereiten bedeutet meist stundenlanges Ausfüllen von Formularen in der Weboberfläche von App Store Connect: Name, Untertitel, Beschreibung und Keywords für jede Sprache, Screenshots für jede Gerätegröße, Preis, Altersfreigabe und schließlich Archiv und Upload aus Xcode. Für Kalecik lief der Großteil dieser Arbeit stattdessen über die App Store Connect API, gesteuert von kleinen Python- und Bash-Skripten.
Dieser Beitrag stellt diese Skripte vor: ein JWT aus dem API-Schlüssel erzeugen, einen Preisplan einrichten, Store-Texte in 50 Sprachen und Regionen prüfen und hochladen, die Screenshots von der Spiel-Engine selbst rendern lassen und einen Build mit einem einzigen Befehl hochladen. Außerdem geht es darum, was die API nicht konnte, und um die Android-Seite. Die allgemeine Reihenfolge der Schritte und den Prüfprozess beschreibe ich in meiner Anleitung zur Veröffentlichung im App Store; hier geht es nur um die Automatisierung.
Der Ehrlichkeit halber: Wie der Rest des Spiels wurden auch diese Skripte von Claude-Code-Agenten geschrieben. Wie das Spiel mit fünf parallelen Sitzungen entstanden ist, lesen Sie im Hauptbeitrag der Serie. Ich habe das Entwicklerkonto eröffnet, Name und Preis gewählt und die Ergebnisse geprüft. Und ein wichtiger Hinweis: Kalecik ist noch nicht im Store. Zwei Builds sind hochgeladen, Store-Texte und Preisplan stehen bereit, aber die Einreichung zur Prüfung habe ich selbst zurückgestellt, nach einer einfachen Regel: Das Spiel kommt erst in den App Store, wenn es wirklich fertig ist.
Die Grundlage: ein JWT aus dem .p8-Schlüssel
Die App Store Connect API erwartet bei jeder Anfrage ein kurzlebiges JWT. Signiert wird es per ES256 mit dem API-Schlüssel, den Sie in App Store Connect anlegen und als .p8-Datei erhalten. tools/asc.py ist der kleine Client, auf dem alle anderen Skripte aufbauen:
import json, os, time, urllib.request
import jwt # PyJWT; für ES256 muss "pyjwt[crypto]" installiert sein
KEY_ID = "<KEY_ID>"
ISSUER = "<ISSUER_ID>"
KEY_PATH = os.path.expanduser(f"~/.appstoreconnect/private_keys/AuthKey_{KEY_ID}.p8")
BASE = "https://api.appstoreconnect.apple.com"
def token() -> str:
with open(KEY_PATH) as f:
key = f.read() # von der Platte gelesen, nirgends ausgegeben
now = int(time.time())
return jwt.encode(
{"iss": ISSUER, "iat": now, "exp": now + 900, "aud": "appstoreconnect-v1"},
key, algorithm="ES256", headers={"kid": KEY_ID, "typ": "JWT"})
def call(method: str, path: str, body=None):
req = urllib.request.Request(BASE + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + token(), "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else {})Drei Details sind wichtig. Das Token gilt 15 Minuten (exp), und jede Anfrage erzeugt ihr eigenes, sodass ablaufende Tokens nie ein Thema sind. Die Schlüsseldatei liegt außerhalb des Projektordners in ~/.appstoreconnect/private_keys/, wo auch Apples Werkzeuge suchen; da sich eine .p8-Datei nur einmal herunterladen lässt, liegt die Sicherung bei Ihnen. Das Dritte ist bei der Arbeit mit Agenten besonders wichtig: Der Inhalt des Schlüssels erscheint in keiner Ausgabe. Der echte Client gibt außerdem Fehlerantworten als JSON zurück, sodass Skripte etwa ein 403 melden können, ohne abzustürzen.
Ein Preisplan mit einem einzigen POST
Preise sind in App Store Connect ein „Preisplan“: ein Basisland und optional manuelle Preise für einzelne Länder. Meine erste Idee waren etwa 50 TL in der Türkei und 3 Dollar im Ausland. Am Ende wurden es 2,99 $ als Basispreis in den USA und manuell 79,99 TL in der Türkei; für andere Länder wie Deutschland leitet Apple den Gegenwert selbst vom Basispreis ab (dort 2,99 €). tools/asc_pricing.py richtet das mit einer einzigen Anfrage an POST /v1/appPriceSchedules ein und kann optional eine Launch-Woche hinzufügen:
def schedule(launch: str | None):
prices = [(US_299, None, None)] # US-Basispreis, unbefristet
if launch:
start = datetime.date.fromisoformat(launch)
end = start + datetime.timedelta(days=7)
prices += [(TR_7999, None, start.isoformat()), # bis zum Launch 79,99 TL
(TR_4999, start.isoformat(), end.isoformat()), # Launch-Woche 49,99 TL
(TR_7999, end.isoformat(), None)] # danach wieder 79,99 TL
else:
prices += [(TR_7999, None, None)]
included, refs = [], []
for i, (point, start, end) in enumerate(prices):
pid = "${price%d}" % i # temporäre ID einer Ressource aus derselben Anfrage
refs.append({"type": "appPrices", "id": pid})
included.append({"type": "appPrices", "id": pid,
"attributes": {"startDate": start, "endDate": end},
"relationships": {"appPricePoint": {"data": {"type": "appPricePoints", "id": point}}}})
body = {"data": {"type": "appPriceSchedules", "relationships": {
"app": {"data": {"type": "apps", "id": "<APP_ID>"}},
"baseTerritory": {"data": {"type": "territories", "id": "USA"}},
"manualPrices": {"data": refs}}},
"included": included}
return call("POST", "/v1/appPriceSchedules", body)US_299 und die anderen sind Apples „Preispunkt“-IDs, die Sie in der appPricePoints-Liste der App, gefiltert nach Land, nachschlagen. Das Schöne daran: Ein neuer Plan ersetzt den alten vollständig. Den Launch-Rabatt kann ich später mit einem einzigen Parameter wie --launch 2026-11-05 neu planen, ohne mich in der Weboberfläche durch Datumsfelder zu klicken.
Store-Texte in 50 Sprachen, mit Regeln
Die Oberfläche des Spiels gibt es in 20 Sprachen, die Store-Texte in 50 Sprachen und Regionen (warum, steht weiter unten). Jede Sprache hat eine Datei store/listing/<locale>.json mit Name, Untertitel, Keywords, Werbetext und Beschreibung. Ohne Argumente sendet tools/asc_metadata.py nichts, es prüft nur. Zuerst Apples Längengrenzen:
LIMITS = {"name": 30, "subtitle": 30, "keywords": 100, "promotionalText": 170, "description": 4000}Dann die Keyword-Regeln. Das Keyword-Feld fasst 100 Zeichen, und jedes zählt; das Skript findet deshalb verschwendete Zeichen (gekürzt):
CATEGORY = {"app", "apps", "game", "games", "oyun", "oyunu", "spiel", "jeu", "juego"}
def keyword_problems(t: dict) -> list[str]:
out = []
kw = t.get("keywords", "")
if ", " in kw:
out.append("Leerzeichen nach Komma") # Leerzeichen zählen zu den 100
items = [k.strip() for k in kw.split(",") if k.strip()]
if len({k.lower() for k in items}) != len(items):
out.append("doppeltes Keyword")
title = (t["name"] + " " + t["subtitle"]).lower()
for k in items:
if k.lower() in title:
out.append(f"{k!r} steht schon in Name oder Untertitel") # diese Wörter sind ohnehin indiziert
if k.lower() in CATEGORY:
out.append(f"{k!r} ist ein Kategoriewort")
return outDas echte Skript erkennt zusätzlich Singular-Plural-Paare und prüft Sprachen ohne Leerzeichen zwischen Wörtern (Chinesisch, Japanisch, Thai) anders. Ist alles sauber, sendet --push die Daten. Das Senden ist idempotent: Existiert eine Sprache bereits, gibt es ein PATCH, sonst ein POST, sodass ein zweiter Lauf dasselbe Ergebnis liefert. Kategorien, Copyright-Zeile und die Antworten zur Altersfreigabe werden im selben Durchgang geschrieben.
Die Lektion aus Portugal
Auf meiner Testliste stand irgendwann „Englisch im portugiesischen Store“. Der Grund ist einfach: Der App Store in Portugal sucht eine pt-PT-Lokalisierung, nicht den pt-BR-Text. Fehlt sie, erscheint die App auf Englisch. Dasselbe gilt für regionale Stores wie Mexiko (es-MX) oder das französischsprachige Kanada (fr-CA). Deshalb kamen zur Sprachliste des Skripts 19 weitere Store-Locales hinzu, die sonst auf Englisch zurückfallen würden (pt-PT, es-MX, fr-CA, en-GB und andere). Die Lehre: „Welche Sprachen unterstütze ich?“ und „Welchen Text zeigt jeder Store?“ sind nicht dieselbe Frage.
Die Lektion mit den Regionscodes
Später habe ich die Liste auf alle Sprachen erweitert, die Apple unterstützt: Bengalisch, Tamil, Urdu, Slowenisch und weitere Sprachen Indiens. Die Texte waren fertig, doch ein Teil dieser Sprachen kam nie in App Store Connect an. Der Grund war wieder der Locale-Code: Für die älteren Sprachen akzeptiert Apple Kurzcodes (sv, hi, tr), für diese neueren verlangt es den Code mit Region. Richtig ist ur-PK statt ur, ta-IN statt ta, sl-SI statt sl. Mit den richtigen Codes kamen diese 11 Sprachen in die Liste des Skripts, insgesamt sind es nun 50. Danach habe ich alles über die API zurückgelesen und gezählt: App-Informationen und Versionstexte gibt es in 50 Locales, kein Feld ist leer. Die Lehre: Lesen Sie nach dem Senden die Daten zurück und zählen Sie nach; keine Fehlermeldung zu sehen heißt nicht, dass der Eintrag gespeichert wurde.
Die Screenshots macht die Spiel-Engine
Statt im Simulator zu spielen und Bilder abzugreifen, lässt tools/store_shots.gd das Spiel in einem SubViewport in exakter Store-Größe laufen. Die Fenstergröße spielt keine Rolle; die Ausgabe hat immer genau die geforderten Pixelmaße:

Oberfläche ausgeblendet, von der Engine selbst gerendert. Die Kalecik-Bilder auf dieser Website entstanden genauso.
## [Ordnername, Pixelgröße]
const DEVICES := [["iphone69", Vector2i(2868, 1320)], ["ipad13", Vector2i(2752, 2064)]]
var sv := SubViewport.new()
sv.size = size # unabhängig vom Fenster: exakte Store-Größe
sv.msaa_3d = Viewport.MSAA_4X
sv.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(sv)
var m: Node = load("res://main.tscn").instantiate()
sv.add_child(m) # das Beispieldorf baut sich selbst auf
# ...
m.ui.visible = false # Oberfläche ausgeblendet: nur das Dorf im Bild
m.rig.set_view(target, yaw, distance) # Kamera und Tageszeit pro Bild
await RenderingServer.frame_post_draw
sv.get_texture().get_image().save_png(file)Pro Gerät gibt es 10 Bilder: das Dorf bei Tag, Abend, Nacht, Winter, Teich und Brücke und so weiter. Kamerawinkel und Tageszeit jedes Bildes stehen in einer Tabelle, und das Wetter wird jedes Mal auf klar gesetzt, damit kein Regen ins Bild zieht. Da die Oberfläche ausgeblendet ist, funktioniert derselbe Satz für alle Sprachen; Bilder mit sichtbarem Spieltext haben sprachspezifische Versionen in einem eigenen Ordner. Kommt eine neue Funktion hinzu, ist der ganze Satz mit einem Befehl neu erzeugt.
tools/asc_screenshots.py lädt sie in drei Schritten hoch. Zuerst werden die PNGs mit dem macOS-Werkzeug sips in JPEG umgewandelt (laut einer Notiz im Code etwa ein Fünftel der Größe), dann:
# 1) reservieren: Dateiname und Größe senden, Upload-Anweisungen zurückbekommen
_, out = call("POST", "/v1/appScreenshots", {"data": {"type": "appScreenshots",
"attributes": {"fileName": name, "fileSize": len(data)},
"relationships": {"appScreenshotSet": {"data": {"type": "appScreenshotSets", "id": set_id}}}}})
shot = out["data"]
# 2) jeden Teil an die URL senden, mit Methode und Headern, die Apple vorgibt
for op in shot["attributes"]["uploadOperations"]:
chunk = data[op["offset"]:op["offset"] + op["length"]]
req = urllib.request.Request(op["url"], data=chunk, method=op["method"],
headers={h["name"]: h["value"] for h in op.get("requestHeaders", [])})
urllib.request.urlopen(req, timeout=120).read()
# 3) bestätigen: die MD5-Prüfsumme belegt, dass die Datei vollständig angekommen ist
call("PATCH", f"/v1/appScreenshots/{shot['id']}", {"data": {"type": "appScreenshots", "id": shot["id"],
"attributes": {"uploaded": True, "sourceFileChecksum": hashlib.md5(data).hexdigest()}}})Derzeit sind Screenshots für en-US und Türkisch hochgeladen. Für weitere Sprachen läuft dasselbe Skript mit dem Parameter --locale=.
Builds mit einem Befehl: ios_release.sh
Der längste Teil der Pipeline ist der Build. tools/ios_release.sh arbeitet in dieser Reihenfolge: Snapshot, Syntaxprüfung, Release-Export aus Godot, Xcode-Archiv, .ipa, Upload. Gekürzt (IDs sind Platzhalter):
# Build-Nummer: eins über dem höheren Wert aus App Store Connect und lokaler Aufzeichnung
BUILD=$(( (LAST_ASC > LAST_LOCAL ? LAST_ASC : LAST_LOCAL) + 1 ))
# Snapshot: andere Agenten-Sitzungen bearbeiten den echten Ordner weiter
rsync -a --delete --exclude .git/ --exclude export/ ./ "$SNAP/"
# Jedes Skript parsen, damit keine halbfertige Änderung ausgeliefert wird
godot --headless --path "$SNAP" --check-only --script "$rel"
godot --headless --path "$SNAP" --export-release "iOS" "$OUT/Kale.ipa"
AUTH=(-allowProvisioningUpdates -authenticationKeyPath "$KEY_PATH"
-authenticationKeyID "<KEY_ID>" -authenticationKeyIssuerID "<ISSUER_ID>")
xcodebuild -project "$PROJ" -scheme "$SCHEME" -configuration Release \
-destination 'generic/platform=iOS' -archivePath "$ARCHIVE" "${AUTH[@]}" \
CURRENT_PROJECT_VERSION=$BUILD archive
xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$IPA_DIR" \
-exportOptionsPlist ExportOptions.plist "${AUTH[@]}"
xcrun altool --upload-app --type ios --file "$IPA" --apiKey "<KEY_ID>" --apiIssuer "<ISSUER_ID>"Die Gründe für einige dieser Entscheidungen:
- Snapshot und Syntaxprüfung. Mehrere Agenten-Sitzungen bearbeiteten das Spiel gleichzeitig. Startete ein Build genau in dem Moment, in dem eine Datei halb geschrieben war, könnte defekter Code in den Store gelangen. Das Skript kopiert deshalb zuerst den Ordner und prüft jede GDScript-Datei der Kopie mit
--check-only; bei einem Fehler bricht es ab und empfiehlt, eine Minute zu warten und es erneut zu versuchen. - Cloud-Signierung. Auf diesem Mac gibt es kein lokales Distributionszertifikat. Mit
-allowProvisioningUpdatesund dem API-Schlüssel überlässt Xcode die Signierung Apples Cloud, ganz ohne Arbeit mit dem Schlüsselbund. - Build-Nummer. App Store Connect akzeptiert dieselbe Build-Nummer nie zweimal. Käme die Nummer nur aus einer lokalen Datei, könnte sie nach einem Upload von einem anderen Rechner oder einem abgebrochenen Upload kollidieren. Das Skript liest die vorhandenen Builds über die API und zählt vom höheren der beiden Werte eins hoch.
- Info.plist-Patch. Nach dem Export ergänzt ein kleines Python-Skript die Begründungstexte für Berechtigungen (Englisch und Türkisch) und die Angabe
ITSAppUsesNonExemptEncryption = false, damit die Verschlüsselungsfrage nicht bei jedem Upload auftaucht.
Über diese Pipeline wurden bisher die Builds 1.0 (1) und 1.0 (2) hochgeladen.
Was die API nicht konnte
Nicht alles lief über die API:
- App-Eintrag anlegen. Der Versuch, die App über die API anzulegen, lieferte ein 403. Der Eintrag selbst, also Name, Bundle-ID und Hauptsprache, entstand in der Weboberfläche von App Store Connect. Alles Weitere ließ sich über die API erledigen.
- Altersfreigabe.
ageRatingDeclarationsdirekt zu lesen (GET) erwies sich als verboten. Gelesen wird stattdessen über die App-Info mitGET /v1/appInfos/{id}/ageRatingDeclaration; zum Schreiben dientPATCH. - Verfügbarkeit in Ländern. Die Ersteinrichtung kann das Skript übernehmen (ohne das chinesische Festland, wo ein kostenpflichtiges Spiel eine Lizenz braucht), spätere Änderungen laufen aber über die Weboberfläche.
Kurz gesagt: Für wiederkehrende Arbeit ist die API hervorragend, für einmalige Einrichtungsschritte braucht es weiterhin die Weboberfläche.
Die Android-Seite: APK und Google Play
Android hat eine eigene, kürzere Pipeline. tools/android.sh exportiert aus Godot ein Debug-APK und installiert und startet es, falls ein Telefon per USB verbunden ist:
godot --headless --path . --export-debug "Android" export/android/Kale.apk
adb install -r export/android/Kale.apk
adb shell monkey -p com.ahmetbalaman.kale -c android.intent.category.LAUNCHER 1Das erste APK entstand in der ersten Nacht des Projekts, und ich habe es einmal einem Freund geschickt, der Android nutzt. Zum Speichern von Fotos in der Galerie war kein zusätzliches Plugin nötig: Ab Android 10 (API 29) schreibt das Spiel über Godots JavaClassWrapper in den MediaStore; diese Korrektur ist auf einem Gerät allerdings noch nicht bestätigt. Ein Debug-APK reicht zum Testen, Google Play verlangt jedoch ein signiertes Release-Paket. Deshalb ist die Google-Play-Version noch in Vorbereitung; den Stand können Sie auf der Kalecik-Seite verfolgen.
Wer wissen möchte, was vor der Veröffentlichungsvorbereitung passierte: Der vorige Beitrag der Serie, Wenn das Spiel das iPhone aufheizt, beschreibt, wie wir das heiß laufende Telefon in den Griff bekommen haben.
Häufige Fragen
Lässt sich mit der App Store Connect API eine neue App anlegen?
In unserem Versuch nicht: Die Anfrage zum Anlegen der App lieferte ein 403, der Eintrag entstand in der Weboberfläche. Die folgenden Schritte wie Preis, Store-Texte, Screenshots, Kategorien und Altersfreigabe ließen sich alle über die API erledigen.
Wie bewahrt man einen App-Store-Connect-API-Schlüssel sicher auf?
Eine .p8-Datei lässt sich nur einmal herunterladen, daher braucht sie eine sichere Sicherungskopie. Bewahren Sie sie außerhalb des Projektordners und des Git-Repositorys auf und geben Sie ihren Inhalt nie in ein Log aus. Übergeben Sie Ihren Skripten nur die Schlüssel-ID und den Dateipfad, und erzeugen Sie für jede Anfrage ein kurzlebiges Token.
Kann man ohne lokales Distributionszertifikat einen Build hochladen?
Ja. Wenn Sie xcodebuild zusammen mit -allowProvisioningUpdates den Pfad, die ID und die Issuer-ID des API-Schlüssels übergeben, wird die Signierung in Apples Cloud verwaltet. Die Builds von Kalecik wurden so hochgeladen, ohne Distributionszertifikat auf dem Rechner.
Welche Größen wurden für die Store-Screenshots verwendet?
2868×1320 für das iPhone (6,9 Zoll, Querformat) und 2752×2064 für das iPad (13 Zoll, Querformat), jeweils 10 Bilder. Die geforderten Größen können sich ändern; prüfen Sie daher vor dem Upload, was App Store Connect gerade verlangt.
Kann ein Godot-Spiel als Debug-APK zu Google Play?
Nein. Ein Debug-APK eignet sich zum Testen auf dem Gerät und zum Teilen mit Freunden. Für Google Play braucht es ein signiertes Release-Paket, und genau an diesem Schritt steht die Google-Play-Version von Kalecik gerade.
Verwandte Artikel
Lizenzfreie Spielmusik: jeden Sound im Code erzeugen
Kalecik nutzt keine Aufnahmen: Steine, Vögel, Schafe und 37 Musikstücke entstanden in Python. Keine Loops, ein Musik-Wächter und LUFS statt Ohren.
Wenn das Spiel das iPhone aufheizt: Godot mobil optimieren
Mein Godot-Spiel hat ein iPhone 15 Pro Max aufgeheizt. Die Ursache waren 120 fps; gelöst mit Framerate-Regler, Schatten-Proxys, Gras-Chunks und Threads.
Prozedurale Steinmauern, Tore und Brücken in Godot
Wie in Kalecik aus einem Fingerstrich eine Steinmauer, ein Torbogen, eine Brücke oder ein Haus wird: Glättung, geseedetes Mauerwerk und Mesh-Chunks.