İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

App Store Connect API ile Yayın Hazırlığı Otomasyonu

Ahmet Balaman

7 dk okuma

Vibe CodingKalecikApp Store ConnectPythonGodotiOSAndroid
App Store Connect API ile Yayın Hazırlığı Otomasyonu

Bir uygulamayı App Store'a hazırlamak, çoğu zaman App Store Connect'in web arayüzünde saatlerce form doldurmak demek: her dil için ad, alt başlık, açıklama ve anahtar kelimeler, her cihaz boyutu için ekran görüntüleri, fiyat, yaş derecelendirmesi, sonra Xcode'dan arşiv ve yükleme. Kalecik için bu işlerin büyük kısmını App Store Connect API'si üzerinden, küçük Python ve Bash betikleriyle yaptık.

Bu yazı o betikleri anlatıyor: API anahtarından JWT üretmek, fiyat takvimi kurmak, 50 dil ve bölgede mağaza metnini kurallara göre denetleyip göndermek, ekran görüntülerini oyun motorunun kendisine çektirmek ve build'i tek komutla yüklemek. API'nin yapamadığı işleri ve Android tarafını da ekledim. Adımların genel sırasını ve inceleme tarafını App Store'a uygulama yayınlama rehberinde anlattığım için burada yalnızca otomasyon kısmına odaklanıyorum.

Dürüst olmak gerekirse: bu betikleri de oyunun geri kalanı gibi Claude Code ajanları yazdı; oyunun beş paralel oturumla nasıl yapıldığını merkez yazıda anlattım. Ben geliştirici hesabını açtım, adı ve fiyatı seçtim, sonuçları kontrol ettim. Bir de önemli not: Kalecik henüz mağazada değil. İki build yüklendi, mağaza metni ve fiyat takvimi hazır, ama oyunu incelemeye göndermeyi "bu oyun bitmeden App Store'a çıkmayalım" diyerek ben erteledim.

Temel: .p8 anahtarından JWT

App Store Connect API her istekte kısa ömürlü bir JWT ister. Bu jeton, App Store Connect'te oluşturduğunuz API anahtarıyla (bir .p8 dosyası) ES256 algoritmasıyla imzalanır. tools/asc.py bütün diğer betiklerin kullandığı küçük istemci:

import json, os, time, urllib.request
import jwt  # PyJWT; ES256 için "pyjwt[crypto]" kurulu olmalı

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()                 # anahtar diskten okunur, hiçbir yere basılmaz
    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 {})

Üç ayrıntı önemli. Jeton 15 dakika geçerli (exp), her istek kendi jetonunu üretiyor, böylece süre dolması diye bir dert kalmıyor. Anahtar dosyası proje klasörünün dışında, Apple araçlarının da baktığı ~/.appstoreconnect/private_keys/ altında duruyor; .p8 yalnızca bir kez indirilebildiği için bu dosyanın yedeği sizin sorumluluğunuzda. Üçüncüsü ajanlarla çalışırken özellikle önemli: anahtarın içeriği hiçbir çıktıya yazdırılmıyor. Asıl istemci hata cevaplarını da JSON olarak döndürüyor, böylece betikler "403" gibi bir sonucu çökmeden raporlayabiliyor.

Fiyat takvimi: tek POST ile

Fiyat için App Store Connect'te bir "fiyat takvimi" kurulur: bir taban ülke ve istenirse ülkelere özel elle fiyatlar. İlk fikrim Türkiye için 50 TL civarı, yurt dışında 3 dolardı. Sonunda ABD'de 2,99 $ taban fiyat ve Türkiye'de elle 79,99 TL oldu; Almanya gibi diğer ülkelerde Apple taban fiyatın karşılığını kendisi hesaplıyor (orada 2,99 €). tools/asc_pricing.py bunu tek bir POST /v1/appPriceSchedules isteğiyle kuruyor ve isteğe bağlı olarak bir açılış haftası ekliyor:

def schedule(launch: str | None):
    prices = [(US_299, None, None)]                          # ABD taban fiyatı, süresiz
    if launch:
        start = datetime.date.fromisoformat(launch)
        end = start + datetime.timedelta(days=7)
        prices += [(TR_7999, None, start.isoformat()),       # açılışa kadar 79,99 TL
                   (TR_4999, start.isoformat(), end.isoformat()),  # açılış haftası 49,99 TL
                   (TR_7999, end.isoformat(), None)]         # sonra yine 79,99 TL
    else:
        prices += [(TR_7999, None, None)]
    included, refs = [], []
    for i, (point, start, end) in enumerate(prices):
        pid = "${price%d}" % i                               # aynı istekte oluşturulan kaynağın geçici kimliği
        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 ve diğerleri Apple'ın "fiyat noktası" kimlikleri; bunlar uygulamanın appPricePoints listesinden ülkeye göre süzülerek bulunuyor. Buradaki güzel taraf: yeni bir takvim eskisinin yerine tamamen geçiyor. Açılış indirimini ileride istediğim tarihe göre --launch 2026-11-05 gibi tek bir parametreyle yeniden planlayabilirim; web arayüzünde tarih tarih tıklamaya gerek yok.

50 dilde mağaza metni ve kurallar

Oyunun arayüzü 20 dilde, mağaza metni ise 50 dil ve bölgede (nedeni aşağıda). Her biri store/listing/<yerel>.json dosyasında duruyor: ad, alt başlık, anahtar kelimeler, tanıtım metni, açıklama. tools/asc_metadata.py parametresiz çalışınca hiçbir şey göndermiyor, yalnızca denetliyor. Önce Apple'ın uzunluk sınırları:

LIMITS = {"name": 30, "subtitle": 30, "keywords": 100, "promotionalText": 170, "description": 4000}

Sonra anahtar kelime kuralları. Anahtar kelime alanı 100 karakter ve her karakter değerli; bu yüzden betik boşa giden karakterleri yakalıyor (kısaltılmış hâli):

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("virgülden sonra boşluk")          # boşluk da 100 karakterden yer
    items = [k.strip() for k in kw.split(",") if k.strip()]
    if len({k.lower() for k in items}) != len(items):
        out.append("tekrar eden kelime")
    title = (t["name"] + " " + t["subtitle"]).lower()
    for k in items:
        if k.lower() in title:
            out.append(f"{k!r} zaten ad ya da alt başlıkta")  # oradaki kelimeler zaten aranıyor
        if k.lower() in CATEGORY:
            out.append(f"{k!r} bir kategori kelimesi")
    return out

Gerçek betik bunlara ek olarak tekil ve çoğul çiftlerini de yakalıyor, kelimeleri boşlukla ayırmayan dillerde (Çince, Japonca, Tayca) farklı bir kontrol yapıyor. Her şey temizse --push ile gönderiliyor. Gönderim idempotent: o dilin kaydı varsa PATCH, yoksa POST. Betik kaç kez çalışırsa çalışsın sonuç aynı; kategoriler, telif satırı ve yaş derecelendirmesi yanıtları da aynı turda yazılıyor.

Portekiz dersi

Test sırasında not ettiğim maddelerden biri "Portekiz mağazasında İngilizce" idi. Sebep basit: Portekiz'in App Store'u pt-BR metnini değil, pt-PT yerel ayarını arıyor. O olmayınca uygulama İngilizce görünüyor. Aynı durum Meksika (es-MX) ya da Kanada'nın Fransızca mağazası (fr-CA) gibi bölgesel mağazalar için de geçerli. Bu yüzden betiğin dil listesine, aksi hâlde İngilizceye düşecek 19 mağaza yerel ayarı daha eklendi (pt-PT, es-MX, fr-CA, en-GB gibi). Ders: "hangi dilleri destekliyorum" sorusu ile "hangi mağazalarda hangi metin görünecek" sorusu aynı soru değil.

Bölge kodu dersi

Sonra listeyi Apple'ın desteklediği bütün dillere tamamladım: Bengalce, Tamilce, Urduca, Slovence ve Hindistan'ın diğer dilleri. Metinler hazırdı ama bu dillerin bir kısmı App Store Connect'e hiç ulaşmamıştı. Sebep yine yerel ayar koduydu: Apple eski dilleri kısa kodla (sv, hi, tr) kabul ederken bu yeni dillerde bölge ekli kodu istiyor. Doğrusu ur değil ur-PK, ta değil ta-IN, sl değil sl-SI. Betiğin listesine 11 dil bu kodlarla eklendi ve toplam 50 oldu. Ardından API'den geri okuyup saydım: uygulama bilgisi de sürüm metni de 50 dilde, hiçbir alan boş değil. Ders: gönderimden sonra API'den geri okuyup sayın; hata görmemek, kaydın oluştuğu anlamına gelmiyor.

Ekran görüntülerini oyun motoru çekiyor

Ekran görüntüsü için simülatörde oynayıp kare yakalamak yerine, tools/store_shots.gd oyun motorunu birebir mağaza boyutunda bir SubViewport içinde çalıştırıyor. Pencere boyutu önemsiz; çıktı her zaman tam piksel ölçüsünde:

Oyun motorunun arayüzü gizleyerek çektiği bir Kalecik karesi

Arayüz gizli, motorun kendi çektiği bir kare. Bu sitedeki Kalecik görselleri de aynı yöntemle çekildi.

## [dosya adı, piksel boyutu]
const DEVICES := [["iphone69", Vector2i(2868, 1320)], ["ipad13", Vector2i(2752, 2064)]]

var sv := SubViewport.new()
sv.size = size                          # pencereden bağımsız, birebir mağaza boyutu
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)                         # örnek köy kendiliğinden kurulur
# ...
m.ui.visible = false                    # arayüz gizli: karede yalnızca köy
m.rig.set_view(target, yaw, distance)   # her kare için kamera ve günün saati
await RenderingServer.frame_post_draw
sv.get_texture().get_image().save_png(file)

Her cihaz için 10 kare var: gündüz köy, akşam, gece, kış, gölet ve köprü gibi. Her karenin kamera açısı ve günün saati bir tabloda tanımlı; yağmurun bir kareye denk gelmemesi için hava her seferinde açık yapılıyor. Arayüz gizli olduğu için aynı set bütün dillerde kullanılabiliyor; oyun içi metin görünen kareler için dile özel sürümler ayrı klasörde. Oyuna yeni bir özellik eklendiğinde bütün seti yeniden üretmek tek komut.

Yükleme tools/asc_screenshots.py ile üç adımda yapılıyor. PNG'ler önce macOS'un sips aracıyla JPEG'e çevriliyor (koddaki nota göre boyutun beşte biri), sonra:

# 1) yer ayır: dosya adı ve boyutu bildirilir, cevapta yükleme talimatları gelir
_, 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) parçaları Apple'ın verdiği adres, yöntem ve başlıklarla gönder
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) onayla: MD5 özeti ile dosyanın eksiksiz geldiği doğrulanır
call("PATCH", f"/v1/appScreenshots/{shot['id']}", {"data": {"type": "appScreenshots", "id": shot["id"],
    "attributes": {"uploaded": True, "sourceFileChecksum": hashlib.md5(data).hexdigest()}}})

Şu an en-US ve Türkçe için ekran görüntüleri yüklü. Diğer diller için aynı betik --locale= parametresiyle çalışıyor.

Tek komutla build: ios_release.sh

Yayın hattının en uzun parçası build. tools/ios_release.sh şu sırayla ilerliyor: anlık görüntü, ayrıştırma kontrolü, Godot'dan release dışa aktarma, Xcode arşivi, .ipa, yükleme. Kısaltılmış hâli (kimlikler yer tutucu):

Kalecik’in yayın otomasyonu: ios_release.sh adımları ve JWT ile App Store Connect API’ye giden fiyat, mağaza metni ve ekran görüntüleri

# Build numarası: App Store Connect'teki en yüksek ile yerel kaydın büyüğü + 1
BUILD=$(( (LAST_ASC > LAST_LOCAL ? LAST_ASC : LAST_LOCAL) + 1 ))

# Anlık görüntü: diğer ajan oturumları gerçek klasörde düzenlemeye devam ediyor
rsync -a --delete --exclude .git/ --exclude export/ ./ "$SNAP/"

# Her betiği ayrıştır: yarım kalmış bir düzenleme build'e girmesin
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>"

Birkaç kararın gerekçesi:

  • Anlık görüntü ve ayrıştırma kontrolü: Oyun aynı anda birkaç ajan oturumunda düzenleniyordu. Build tam birinin dosyayı yarım bıraktığı anda başlarsa bozuk kod mağazaya gidebilirdi. Önce kopya alınıyor, kopyadaki her GDScript dosyası --check-only ile denetleniyor; hata varsa betik "bir dakika bekleyip yeniden dene" diyerek duruyor.
  • Bulut imzalama: Bu Mac'te yerel bir dağıtım sertifikası yok. -allowProvisioningUpdates ile API anahtarı birlikte verilince Xcode imzalamayı Apple'ın bulut tarafında yönetiyor; anahtarlık ile uğraşmak gerekmiyor.
  • Build numarası: App Store Connect aynı build numarasını ikinci kez kabul etmiyor. Numara yalnızca yerel dosyadan gelseydi, başka bir makineden ya da yarıda kalmış bir yüklemeden sonra çakışabilirdi. Betik API'den mevcut build'leri okuyup ikisinin büyüğüne bir ekliyor.
  • Info.plist yaması: Dışa aktarmadan sonra küçük bir Python betiği izin açıklamalarını (İngilizce ve Türkçe) ve ITSAppUsesNonExemptEncryption = false beyanını ekliyor; her yüklemede şifreleme sorusu gelmiyor.

Bu hatla şimdiye kadar 1.0 (1) ve 1.0 (2) build'leri yüklendi.

API'nin yapamadıkları

Her şey API ile olmadı:

  • Uygulama kaydı: Uygulamayı API ile oluşturma denemesi 403 döndü. Kayıt, yani ad, bundle ID ve birincil dil, App Store Connect web arayüzünden açıldı. Sonraki her şey API ile yapılabildi.
  • Yaş derecelendirmesi: ageRatingDeclarations kaynağını doğrudan okumak (GET) yasak çıktı. Okuma, uygulama bilgisinin altından GET /v1/appInfos/{id}/ageRatingDeclaration ile yapılıyor; yazmak için PATCH kullanılıyor.
  • Ülke erişilebilirliği: Betik ilk kurulumu yapabiliyor (ücretli oyunlar için lisans gerektiği için Çin anakarası hariç), ama kurulduktan sonraki değişiklikler web arayüzünden yapılıyor.

Kısacası API, tekrar eden işlerde çok güçlü; bir kerelik kayıt adımlarında ise web arayüzü hâlâ gerekli.

Android tarafı: APK ve Google Play

Android için ayrı bir hat var ama daha kısa. tools/android.sh Godot'dan debug APK üretiyor ve USB ile bağlı bir telefon varsa kurup açıyor:

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 1

İlk APK, projenin ilk gecesinde çıktı; APK'yı bir kez Android kullanan bir arkadaşıma gönderdim. Galeriye fotoğraf kaydetme için ek bir eklenti yazılmadı: Android 10 (API 29) ve üstünde MediaStore'a Godot'nun JavaClassWrapper'ı üzerinden yazılıyor; bu düzeltme henüz cihazda doğrulanmadı. Debug APK denemek için yeterli, ama Google Play imzalı bir release paketi istiyor. Google Play sürümü bu yüzden hazırlık aşamasında; güncel durumu Kalecik sayfasında görebilirsiniz.

Serinin önceki yazısı iPhone'un ısınması ve Godot mobil performansı ise yayına hazırlanırken telefonun ısınma sorununu nasıl çözdüğümüzü anlatıyor.

Sık Sorulan Sorular

App Store Connect API ile yeni uygulama oluşturulabilir mi?

Bizim denememizde hayır: uygulama oluşturma isteği 403 döndü. Kayıt web arayüzünden açıldı. Sonrasındaki fiyat, mağaza metni, ekran görüntüsü, kategori ve yaş derecelendirmesi gibi adımlar API ile yapılabildi.

App Store Connect API anahtarı nasıl güvende tutulur?

.p8 dosyası yalnızca bir kez indirilebildiği için güvenli bir yedeği olmalı. Proje klasörünün ve git deposunun dışında tutun, içeriğini hiçbir günlüğe yazdırmayın. Betiklere yalnızca anahtar kimliğini ve dosya yolunu verin; jetonu her istekte kısa ömürlü olarak üretin.

Yerel dağıtım sertifikası olmadan App Store'a build yüklenir mi?

Evet. xcodebuild komutuna -allowProvisioningUpdates ile birlikte API anahtarının yolu, kimliği ve issuer kimliği verildiğinde imzalama Apple'ın bulut tarafında yönetiliyor. Kalecik'in build'leri bu yolla, makinede dağıtım sertifikası olmadan yüklendi.

Mağaza ekran görüntüleri için hangi boyutlar kullanıldı?

iPhone için 2868×1320 (6,9 inç, yatay), iPad için 2752×2064 (13 inç, yatay), her biri 10 kare. Zorunlu boyutlar zamanla değişebildiği için yüklemeden önce App Store Connect'in o anda istediği boyutları kontrol edin.

Godot oyunu Google Play'e debug APK ile gönderilebilir mi?

Hayır. Debug APK cihazda denemek ve arkadaşlarla paylaşmak için uygun. Google Play'e gönderim için imzalı bir release paketi gerekiyor. Kalecik'in Google Play sürümü bu adımda hazırlanıyor.

Yorumlar