Back to deblo
deblo

Onze bugs entre le submit et le ship : une session de soumission double-store de cinq heures, déroulée bug par bug, des podspecs RCT-Folly aux pages mémoire de seize kilo-octets

Onze bugs distincts trouvés et livrés en une seule session double-store de cinq heures, du podspec RCT-Folly sous Expo SDK 54 à l'avertissement Android sur les pages mémoire de seize kilo-octets. Bug par bug, ce qui a cassé, à quoi a ressemblé le fix, lesquels ont nécessité une couche de persistance en suivi, et celui que nous avons reporté proprement à versionCode 3.

Juste A. Gnimavo (Thales) & Claude | May 27, 2026 42 min deblo
EN/ FR/ ES
debloclaude-opus-4.7claude-codeapple-app-storegoogle-playreact-nativeexpo-sdk-54rn-0.81-new-architecturereact-native-iaplivekitstorekit-2app-store-server-api-v2jwsx5c-chainpython-josedeviceeventemittergradlemissing-dimension-strategykotlinpatch-packageexpo-config-plugin16kb-page-sizendksentry-sourcemapspost-mortemdual-store-submit

Par Thales (CEO, ZeroSuite) & Claude Opus 4.7 — instance Claude Code

La session a commencé à 20h00 UTC le 27 mai 2026, avec ce que nous pensions être une corvée de clôture. L'intégration Apple In-App Purchase avait atterri deux jours plus tôt sur les sessions 252 et 253. La refonte de la modale privacy et le hard-delete pricing iOS avaient atterri en session 254. Le fix du host de deep-link Android et son symétrique iOS Universal Links étaient tous deux sur main. Nous avions quatre comptes de démo peuplés et prêts pour le reviewer Apple. Le plan de la soirée : bumper versionCode et buildNumber, lancer expo prebuild, archiver dans Xcode, uploader via Organizer, et cliquer Submit dans App Store Connect. Puis répliquer le même flow sur Android : gradlew bundleRelease, upload vers Play Console, clic Submit. Deux stores, une session, trois heures peut-être.

La session a duré cinq heures. Elle s'est terminée à 01h00 UTC le 28 mai, les deux stores à l'état submitted, in review, mais seulement après avoir trouvé et corrigé onze bugs distincts en direct, en séquence, chacun shippé avant que le suivant ne soit abordé. Certains bugs étaient dans notre code. D'autres dans les SDK dont nous dépendons. D'autres dans notre compréhension de la façon dont le reviewer Apple allait réellement exercer l'app. Un était dans un fichier de configuration qu'Apple n'avait pas régénéré quand nous avions ajouté une nouvelle capability six mois plus tôt. Le fix le plus long a pris quatre-vingt-dix minutes ; le plus court, trois.

Ce billet déroule chaque bug dans l'ordre, nomme ce que c'était, ce qu'il a coûté, et à quoi ressemblait le fix. Huit des onze ont été shippés dans 1.0.6 directement. Trois ont nécessité du travail de persistance qui a été shippé en commit de suivi plus tard cette nuit-là. Aucun n'était reportable au-delà du submit ; tous étaient bloquants dans une direction ou une autre.

La liste condensée : podspec RCT-Folly manquant sous les dépendances préconstruites RN 0.81, frère de fallback Expo Router requis, capacité Associated Domains manquante dans le provisioning profile, mauvaise détection de l'environnement sandbox versus production StoreKit 2, vérification JWS python-jose attendant un autre format de PEM, listener DeviceEventEmitter monté sur le mauvais écran, écran credits iOS partagé entre wallet et ancien compteur de credits, timeout d'upload des sourcemaps Sentry, ambiguïté de variant Gradle React Native IAP entre flavors play et amazon, erreur de compilation Kotlin React Native IAP sous RN 0.81 New Architecture, erreur soft de page mémoire seize kilo-octets sur Play Console. L'ordre dans lequel je les écris est l'ordre dans lequel nous les avons rencontrés, qui est aussi l'ordre dans lequel ils surgiraient pour n'importe quelle équipe parcourant le même chemin d'upgrade sur la même matrice SDK la même semaine.


Partie 1 — Bug 1 : pod install ne trouve pas RCT-Folly

Le premier signe que cette session ne serait pas propre est arrivé une minute après avoir lancé npx expo prebuild --platform ios. Le prebuild a généré ios/Deblo.xcworkspace, le répertoire Pods/ a été scaffoldé, et cd ios && pod install a échoué avec :

Unable to find a specification for `RCT-Folly`

RCT-Folly est le fork de Folly que React Native vendore. Jusqu'à React Native 0.78, il était shippé comme une spec CocoaPods autonome dont vous dépendiez depuis n'importe quel module qui avait besoin des types Folly. À partir de 0.78, l'équipe React Native a introduit un chemin de dépendances React Native préconstruites (RCT_USE_RN_DEP=1) qui bundle Folly avec le reste du runtime React Native dans un seul artefact, supprimant la nécessité pour les modules individuels de déclarer une ligne s.dependency "RCT-Folly".

Le problème, c'est que toutes les specs CocoaPods de l'écosystème n'ont pas rattrapé le coup. Le podspec de [email protected], la version que nous avions installée pour l'intégration StoreKit 2, contient encore :

rubyif ENV['RCT_NEW_ARCH_ENABLED'] == '1'
  s.dependency "RCT-Folly"
end

Nous avions RCT_NEW_ARCH_ENABLED=1 parce que nous sommes sur la New Architecture dans toute l'app. Nous avions RCT_USE_RN_DEP=1 parce que c'est le défaut Expo SDK 54 pour les dépendances React Native préconstruites. La combinaison fait que react-native-iap déclare une dépendance sur un podspec que le chemin préconstruit n'expose pas, et CocoaPods échoue sans aucun diagnostic sur le pourquoi.

Le fix est de faire opt-out ce build des dépendances React Native préconstruites sur iOS. Le plugin Expo qui contrôle ça est expo-build-properties, configuré dans app.json :

json{
  "expo": {
    "plugins": [
      [
        "expo-build-properties",
        {
          "ios": {
            "buildReactNativeFromSource": true
          }
        }
      ]
    ]
  }
}

buildReactNativeFromSource: true force RN à compiler depuis les sources sur iOS plutôt qu'utiliser le tarball préconstruit. La compilation est plus lente (environ 60 secondes ajoutées à la première archive de la journée) mais ça signifie que RCT-Folly est exposé comme un podspec normal dont les modules en aval peuvent dépendre. Le flag survit à tous les futurs prebuilds parce qu'il vit dans app.json.

L'installation du package elle-même a été un simple npm install expo-build-properties à la racine du monorepo, repris par le prebuild suivant. Temps passé : quarante-cinq minutes (la plupart à lire les sources de react-native-iap et expo-build-properties pour confirmer le diagnostic avant de commiter). Shippé dans le commit 8baf4f6.

La leçon est inconfortable. Les dépendances natives déclarent des exigences dures à la couche podspec que les bumps de version React Native peuvent invalider silencieusement. Le seul chemin de découverte, c'est pod install qui échoue avec un message opaque. Pour les équipes qui tournent sur le bord traînant des versions React Native (nous, trois semaines après la stabilisation de 0.81), cette classe de bug est attendue. Pour les équipes sur 0.76 ou 0.77, le bug n'existerait pas encore — elles le rencontreraient en upgradant.


Partie 2 — Bug 2 : Expo Router exige un frère de fallback

pod install débloqué, l'échec suivant est arrivé au moment du build Xcode. Metro a commencé le bundling, et quelques secondes après :

Error: app/pricing.ios.tsx does not have a fallback sibling.

Nous avions introduit des fichiers pricing spécifiques à la plateforme en session 254 dans le cadre du hard-delete iOS 3.1.1 (la guideline Apple qui interdit l'affichage de prix de contenu digital en dehors des IAP). app/pricing.ios.tsx retourne un composant déclaratif <Redirect href="/" /> ; app/pricing.android.tsx rend la grille tarifaire FCFA complète. Il n'y avait pas de app/pricing.tsx.

Expo Router énumère les routes au boot et exige un .tsx de base pour chaque fichier étendu par plateforme, même quand les deux fichiers étendus par plateforme existent. Le raisonnement, c'est que Metro choisit le fichier spécifique à la plateforme au moment du bundle, mais la table des routes du router est construite avant que Metro ne résolve les plateformes, donc elle a besoin d'une base pour raisonner. Une base manquante se manifeste comme une erreur au build spécifique à la plateforme dont le bundle est en cours de construction.

Le fix est un app/pricing.tsx de cinq lignes qui retourne aussi un <Redirect href="/" />. Metro choisit .ios.tsx sur iOS et .android.tsx sur Android, donc le fichier de base ne s'exécute que sur les plateformes que nous ne shippons pas (web, que nous ne ciblons pas). Le fichier ajouté dans le commit 8baf4f6 à côté du plugin build-properties :

tsximport { Redirect } from 'expo-router';

export default function PricingFallback() {
  return <Redirect href="/" />;
}

Temps passé : trois minutes de l'erreur au commit. La leçon est microscopique — si vous créez app/foo.ios.tsx, créez aussi app/foo.tsx — mais le message d'erreur d'Expo Router ne dit pas pourquoi le fallback est requis, seulement qu'il l'est. Une future version d'Expo Router pourra relâcher cette contrainte ; nous en bénéficierons sans rien faire. Aujourd'hui, on ajoute le fichier de cinq lignes.


Partie 3 — Bug 3 : provisioning profile sans la capacité Associated Domains

Le build compilant maintenant, Xcode a produit une erreur fraîche à l'étape de signature :

Provisioning profile "iOS Team Provisioning Profile: ai.deblo.app" doesn't include the Associated Domains capability.

Associated Domains est la capability iOS qui alimente les Universal Links — l'équivalent côté iOS des Android App Links. Nous avions ajouté applinks:deblo.ai à app.json -> ios.associatedDomains dans le commit 9ec9b2b six mois plus tôt dans le cadre du travail Universal Links iOS. Le prebuild a correctement écrit le fichier Deblo.entitlements avec la nouvelle capability. Le provisioning profile du Developer Portal d'Apple pour l'App ID ai.deblo.app, en revanche, avait été généré avant que cette capability n'existe, et Apple ne régénère pas automatiquement les provisioning profiles quand des capabilities sont ajoutées — le développeur doit déclencher l'opération.

Le fix est un clic dans Xcode : Signing & Capabilities → bouton "Try Again" sous l'erreur de provisioning profile. Xcode tape le Developer Portal, ajoute Associated Domains à l'App ID, régénère le provisioning profile, et le retélécharge. Le build récupère le nouveau profil et continue.

Temps passé : cinq minutes en incluant l'aller-retour au portail Apple. La leçon, c'est que les ajouts de capabilities dans app.json sont un commit partiel — ils mettent à jour le fichier d'entitlements et les métadonnées de prebuild, mais le développeur doit synchroniser manuellement le provisioning profile, une fois par capability, en déclenchant le flow "Try Again" de Xcode. Il n'y a pas de façon CI-friendly de faire ça ; le portail développeur n'a pas de CLI pour ce cas spécifique. Nous acceptons l'étape manuelle comme un coût semestriel — chaque nouvelle capability vaut une interaction Xcode supplémentaire.

La leçon généralisée : tout ajout dans app.json ou dans les entitlements nécessite un gate explicite "le premier build après l'ajout doit tourner sur une machine de développeur avec Xcode et accès au Developer Portal d'Apple" avant la prochaine archive. Nous avions passé ce gate informellement il y a six mois en ajoutant associatedDomains ; nous n'avions pas relancé d'archive entre ce moment et ce soir, donc l'étape manuelle n'avait pas surgi avant ce soir. Un process propre l'aurait fait surgir le jour de l'ajout de la capability.


Partie 4 — Bug 4 : mauvaise détection sandbox versus production sur StoreKit 2

Le build étant maintenant signé et uploadé vers App Store Connect, la soumission iOS était effectivement faite du point de vue de Xcode. Nous sommes passés aux tests IAP en direct sur l'iPhone sandbox-signé que le CEO avait configuré en phase 1.4 de l'intégration IAP.

Le premier achat sandbox est passé proprement du côté Apple. StoreKit 2 a renvoyé une transaction vérifiée. L'app mobile a appelé l'endpoint backend /api/credits/apple-iap/verify avec le jwsRepresentation de la transaction. Le backend a loggé :

Apple receipt invalid for tx 2000001178213814: Apple API 401 (env=Production). Likely sandbox/prod mismatch.

La réponse 422 est remontée à l'app mobile, le wallet n'a pas été crédité, et le toast de célébration que nous avions construit pour le chemin de succès IAP ne s'est pas déclenché. La carte sandbox du CEO avait été débitée de quatre-vingt-dix-neuf cents (remboursée par Apple plus tard — les achats sandbox sont auto-remboursés), mais le wallet montrait un solde inchangé.

La cause racine était dans iapService.ts. La détection de l'environnement pour décider quel endpoint Apple de vérification utiliser (sandbox versus production) était héritée de l'ère StoreKit 1, où les receipts étaient des blobs PKCS7 base64 qui contenaient la chaîne littérale "sandbox" quelque part dans le plaintext. La détection était :

tsconst env = transactionReceipt.includes('sandbox')
  ? 'Sandbox'
  : 'Production';

StoreKit 2, que react-native-iap@12 utilise sur iOS 15+, ne renvoie pas de receipt PKCS7. Il renvoie un jwsRepresentation — une JSON Web Signature dont le payload est base64-encodé et dont les claims iss et bid identifient l'émetteur mais pas l'environnement. La chaîne "sandbox" n'apparaît pas dans la JWS pour une transaction sandbox. La détection tombait toujours sur 'Production', le backend tapait toujours l'endpoint production en premier, et l'endpoint production renvoyait toujours 401 parce que la transaction avait en fait été émise par l'infrastructure sandbox d'Apple.

Nous avions deux chemins pour fixer. Soit mettre à jour iapService.ts pour extraire l'environnement du payload JWS (la librairie App Store Server fournit un helper). Soit rendre le backend résilient aux bugs de détection d'environnement côté client en essayant l'environnement suggéré par le client d'abord et en basculant vers l'environnement opposé sur 401 ou 404. Le second est strictement meilleur parce qu'il fonctionne pour le dev-client, TestFlight et les builds production indépendamment des bugs côté client, et parce que la documentation legacy verifyReceipt d'Apple elle-même prescrivait ce pattern de fallback dans l'ère StoreKit 1.

Le fix backend est dans backend/app/services/apple_iap.py. La fonction verify_transaction a été refactorée pour extraire un helper interne _verify_against_apple qui renvoie None sur 401/404 (traitant ces codes comme "mauvais environnement, essaye l'autre") et relève toute autre erreur. Le wrapper essaye l'environnement suggéré par le client d'abord, puis bascule vers l'environnement opposé une fois :

pythonasync def verify_transaction(jws_rep: str, env_hint: str) -> AppleTxn:
    primary = env_hint
    fallback = "Sandbox" if env_hint == "Production" else "Production"

    result = await _verify_against_apple(jws_rep, primary)
    if result is None:
        result = await _verify_against_apple(jws_rep, fallback)
    if result is None:
        raise InvalidReceipt(f"Apple returned 401/404 in both envs")

    return result

Le coût en régime stable est un appel API Apple supplémentaire par transaction sandbox (négligeable) et zéro coût supplémentaire pour les transactions production (le hint client sera généralement correct après qu'on aura fixé iapService.ts, mais le backend ne dépend plus du fait que le client ait raison). Temps passé : trente minutes de l'erreur au fix déployé, en incluant la lecture de la documentation App Store Server API V2 pour confirmer que 401 et 404 sont les bons signaux pour "mauvais env". Shippé dans le commit 8baf4f6.

La leçon se généralise au-delà de StoreKit. Quand vous vérifiez des receipts ou des tokens émis par une partie externe, ne vous reposez pas sur le client pour vous dire quel endpoint de validation taper. Le client est le narrateur le moins fiable de la chaîne. Essayez le hint, basculez sur le code d'erreur prévisible, traitez le chemin de fallback comme un coût en régime stable plutôt qu'exceptionnel.


Partie 5 — Bug 5 : crash de vérification JWS sur PEM de certificat versus PEM SubjectPublicKeyInfo

Le Bug 4 fixé, le backend a passé le mismatch d'environnement (Production → 401, fallback vers Sandbox → 200, payload JWS récupéré depuis Apple). Il a ensuite crashé à l'étape suivante : vérification de la signature JWS contre le certificat feuille dans la chaîne x5c.

La stack trace finissait par :

jose.exceptions.JWKError: Valid PEM but no BEGIN/END delimiters for a private key

Le message d'erreur était activement trompeur. Nous n'essayions pas de charger une clé privée ; nous essayions de vérifier une signature contre une clé publique extraite d'un certificat feuille. La phrase "no BEGIN/END delimiters for a private key" nous a envoyés brièvement regarder la variable d'environnement APPLE_IAP_ROOT_CA_PEM, le réglage APPLE_IAP_AUDIENCE, et l'env Easypanel production — aucun n'était la cause.

La cause réelle était dans apple_iap.py:325, dans parse_jws_signed_payload :

pythonleaf_pem = leaf.public_bytes(serialization.Encoding.PEM)
payload = jwt.decode(jws_rep, key=leaf_pem, algorithms=["ES256"], ...)

leaf est un objet x509.Certificate représentant le cert feuille dans la chaîne x5c de la JWS. La méthode public_bytes(Encoding.PEM) renvoie le PEM du certificat (-----BEGIN CERTIFICATE-----), pas le PEM de la clé publique. Le constructeur JWK de python-jose veut un PEM SubjectPublicKeyInfo (-----BEGIN PUBLIC KEY-----), pas un certificat. L'erreur trompeuse "no private key delimiters" vient du parser PEM de python-jose qui échoue sur le préfixe certificat et tombe sur un chemin de code générique "this is not a key" qui mentionne les clés privées pour des raisons historiques.

Le fix est un changement d'une seule ligne. Au lieu de renvoyer le PEM du certificat, extraire la clé publique du certificat et renvoyer son PEM SubjectPublicKeyInfo :

pythonleaf_public_key = leaf.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
payload = jwt.decode(jws_rep, key=leaf_public_key, algorithms=["ES256"], ...)

Temps passé : quarante minutes, la plupart à chasser le message d'erreur trompeur avant de lire la source python-jose pour trouver l'attente réelle de format PEM. Shippé dans le commit a139474.

La leçon ici a deux parties. La leçon étroite, c'est que public_bytes(Encoding.PEM) de la librairie cryptography sur un x509.Certificate renvoie le certificat, pas la clé. Pour obtenir la clé, vous appelez .public_key().public_bytes(...). La leçon plus large concerne les messages d'erreur : "no BEGIN/END delimiters for a private key" de python-jose est une erreur de fall-through qui se déclenche quand le parser ne peut pas interpréter l'input comme aucun des formats qu'il attend, avec le message nommant le dernier format essayé plutôt que le format que l'utilisateur a effectivement fourni. Quand vous débuggez des erreurs de librairies cryptographiques, le message pointe souvent vers la mauvaise chose parce que le parser parcourt les formats dans un ordre fixe.

Nous avons ajouté un commentaire sentinelle près du fix pour documenter ça pour les futurs lecteurs :

python# leaf.public_bytes() returns the CERTIFICATE PEM, not the public key.
# python-jose wants the SubjectPublicKeyInfo PEM. Misleading error if wrong.

Partie 6 — Bug 6 : listener DeviceEventEmitter sur le mauvais écran

Le flow IAP fonctionnait maintenant de bout en bout sur l'appareil. Apple renvoyait 200 sur l'env sandbox (via fallback), le backend vérifiait la JWS, le wallet créditait en base, la transaction était enregistrée avec le bon user, produit et montant. Nous voyions la ligne dans Postgres. Nous ne voyions pas le changement dans l'app.

L'UI mobile montrait la même pillule de solde wallet avant et après l'achat. Le toast de célébration user-facing — "Wallet topped up successfully" — ne se déclenchait pas. La route /wallet montrait l'ancien solde. Tirer pour rafraîchir le mettait à jour. L'utilisateur, en l'absence de pull-to-refresh, penserait que l'achat avait silencieusement échoué.

La cause racine était un listener monté sur le mauvais composant React Native. iapService.ts ligne 164 émet un événement DeviceEventEmitter après un verify réussi :

tsDeviceEventEmitter.emit('IAP_WALLET_TOPPED_UP_EVENT', {
  newBalanceUsdMicro: result.newBalanceUsdMicro,
  productId: result.productId,
});

Le seul listener pour cet événement vivait dans app/credits.tsx :

tsxuseEffect(() => {
  const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
    restoreSession();
  });
  return () => sub.remove();
}, []);

Deux problèmes avec ça. Premièrement, restoreSession() met à jour l'objet user authentifié (qui porte un compteur credits legacy) mais n'appelle pas useWalletStore.refreshBalance() (qui est la source de vérité pour le vrai solde wallet USD-micro). La pillule wallet est bindée au store, pas à l'objet user, donc elle ne se met pas à jour. Deuxièmement, le listener n'est monté que quand l'utilisateur est sur app/credits.tsx. Si l'utilisateur a navigué ailleurs en plein achat, ou si Apple rejoue une transaction en file d'attente au cold-boot de l'app (ce que StoreKit 2 fait pour les transactions interrompues par des problèmes réseau sur la session précédente), l'événement se déclenche et aucun listener ne l'attrape.

Le fix est conceptuel en deux lignes, littéral en dix. Nous avons ajouté un listener global dans _layout.tsx, monté à la racine de l'arbre de navigation, qui appelle useWalletStore.getState().refreshBalance() sur chaque événement IAP :

tsxuseEffect(() => {
  const sub = DeviceEventEmitter.addListener('IAP_WALLET_TOPPED_UP_EVENT', () => {
    useWalletStore.getState().refreshBalance();
  });
  return () => sub.remove();
}, []);

Le listener de l'écran credits est resté parce qu'il gère l'UX locale à l'écran (l'animation de célébration, le reset d'état du bouton). Les deux listeners sont maintenant complémentaires : le global met à jour le store wallet indépendamment de l'écran monté ; le local à l'écran gère le feedback spécifique à l'écran quand l'utilisateur se trouve sur credits.

Les replays au boot fonctionnent aussi maintenant. Quand _layout.tsx se monte au cold boot, le listener global est enregistré avant que StoreKit 2 n'ait l'occasion de rejouer les transactions en file. Quand une transaction rejouée passe par le chemin verify, l'événement se déclenche, le listener global l'attrape, le store wallet se rafraîchit, et la pillule se met à jour — tout cela sans que l'utilisateur n'ouvre jamais l'écran credits.

Temps passé : vingt-cinq minutes de l'observation au fix déployé. Shippé dans le commit 4bd3308.

La leçon se généralise à tout flow d'événements fire-and-forget. Si le listener est monté sur un écran spécifique, l'événement ne se déclenche que quand cet écran est monté. Si le cas d'usage inclut des replays au boot, de la dérive navigationnelle ou des événements en arrière-plan, le listener appartient au layout racine, pas à un écran spécifique. Les listeners locaux à l'écran sont corrects pour l'UX locale à l'écran (animations, états de boutons) ; les listeners au layout racine sont corrects pour l'état global (le wallet, l'utilisateur, le token d'auth).


Partie 7 — Bug 7 : écran credits iOS partagé entre wallet et ancien compteur de credits

Le flow IAP fonctionnait maintenant techniquement. La pillule wallet se mettait à jour. Le toast se déclenchait. Le CEO a fait un smoke test, envoyé l'enregistrement d'écran au reviewer Apple, et attendu la prochaine décision Apple.

Puis il a ouvert /credits et remarqué que l'écran affichait des chiffres contradictoires. La pillule wallet en haut de chaque écran, y compris /credits, affichait $1,516.80 (le vrai solde USD depuis useWalletStore). La carte de solde dans /credits affichait 166 credits (le champ user.credits legacy depuis le store d'auth, en anciennes unités de crédit qui ne correspondent plus à de l'argent réel sur iOS). Après un achat IAP réussi de $1,99, la pillule wallet est passée correctement de $1,516.80 à $1,518.79. Le compteur de credits dans /credits est resté à 166. Pour l'utilisateur, ça ressemble à un achat qui n'aurait pas crédité, même s'il a crédité, parce que l'écran affiche la mauvaise source de vérité.

Le partage est historique. Avant l'intégration Apple IAP, chaque transaction wallet (top-up mobile money XPAYE, top-up carte ZeroFee) mettait à jour à la fois le solde wallet USD-micro et le compteur de credits dérivé. Le compteur de credits est une dérivation de présentation — credits par USD, avec des taux différents pour K12 versus Pro — et l'ancien chemin de code l'affichait sur l'écran credits parce que c'était historiquement la devise user-facing. Après l'intégration Apple IAP, les utilisateurs iOS top-uppent le wallet directement en USD-micro, sans dérivation credits au moment du top-up. Le compteur de credits reste comme un champ dérivé pour la consommation (un message chat débite N credits, où N est calculé depuis le solde wallet courant de l'utilisateur et le taux par feature au moment de la consommation). Mais le compteur de credits n'est plus la bonne chose à afficher sur iOS au moment du top-up, parce que l'utilisateur vient de payer de vrais USD et veut voir des USD.

Le fix est une refonte de labels iOS-only dans credits.tsx. Le flow Android XPAYE / ZeroFee n'est pas touché parce qu'il utilise encore le modèle d'affichage credits-first. Le chemin iOS lit maintenant conditionnellement useWalletStore plus renderComponents (le formateur de devise) et affiche le vrai solde USD-micro dans la devise d'affichage de l'utilisateur :

tsx{Platform.OS === 'ios' ? (
  <BalanceCard
    title={t('deblo.credits.ios_wallet.balance_title')}
    amount={renderComponents(walletStore.balanceUsdMicro, userCurrency)}
    hint={t('deblo.credits.ios_wallet.balance_hint')}
  />
) : (
  <BalanceCard
    title={t('deblo.credits.android_credits.balance_title')}
    amount={`${user.credits} ${t('deblo.credits.credits_unit')}`}
    hint={t('deblo.credits.android_credits.balance_hint')}
  />
)}

Les labels ont été réécrits en parallèle. Le titre du header passe de "Top up credits" à "Top up wallet" sur iOS. Le texte de hint change de "Your credits don't expire" à "Your balance is real money — top up anytime, never expires". Le label du bouton d'achat de pack change de "Buy" à "Recharge" (qui est aussi le verbe français dans les deux registres). Le CTA depuis /wallet change de "Buy credits" à "Recharge wallet" en EN ; la version FR était déjà "Recharger".

Quatre nouvelles clés i18n ont été ajoutées sous deblo.credits.ios_wallet dans en.json et fr.json pour la formulation spécifique à iOS. Les versions françaises incluent les caractères accentués selon notre règle cross-projet permanente selon laquelle le contenu français doit être orthographiquement correct indépendamment de la façon dont l'utilisateur tape dans le chat.

Temps passé : quarante-cinq minutes en incluant l'écriture des clés i18n, la réécriture des labels deux fois (la première version avait une mention de "credits" à l'intérieur du hint iOS, que nous avons attrapée et supprimée), et un petit diff visuel pour confirmer que le layout de la carte iOS correspondait à celui d'Android. Shippé dans le commit eed54bc.

La leçon concerne la migration de source de vérité. Quand vous séparez un système wallet (argent réel) d'un système credits (présentation dérivée), les écrans qui affichaient précédemment des credits doivent être audités pour savoir s'ils affichent des credits (ce qui est maintenant faux sur le chemin wallet) ou dépensent des credits (ce qui reste correct parce que les credits sont l'unité de consommation). L'écran credits sur iOS faisait les deux — affichait le compteur de credits legacy en haut, mais débitait du wallet dans les transactions réelles. La moitié affichage était le bug.


Partie 8 — Bug 8 : timeout d'upload des sourcemaps Sentry

Tous les six fixes précédents déployés, nous avons lancé l'archive Xcode une dernière fois, prêts à uploader via Organizer. L'archive a réussi. La phase d'upload des sourcemaps Sentry qui tourne à la fin de l'archive a échoué :

error: sentry-cli [26] Failed to open/read local data from file/application (Recv failure: Operation timed out)

La phase d'upload des sourcemaps de Sentry est câblée via le plugin Sentry React Native pendant le build Xcode. Elle exécute sentry-cli sourcemaps upload --release [email protected]+5 dist/ à la fin de l'archive, ce qui streame le bundle généré et les sourcemaps vers l'endpoint d'ingest Sentry. L'endpoint a timeout, l'upload a échoué, et l'archive a échoué parce que la phase d'upload est câblée comme une étape requise dans les Build Phases Xcode.

Nous avions deux options. Fixer l'upload (debug du réseau, retry, changement d'endpoint). Ou skipper l'upload et accepter le coût (numéros de ligne minifiés dans les crash reports 1.0.6 jusqu'à ce que les sourcemaps soient backfillées plus tard). Le CEO était dans la fenêtre de submit, le reviewer Apple ne regarderait pas les sourcemaps, et le coût du skip était tolérable.

Le fix est une variable d'environnement de scheme Xcode : SENTRY_ALLOW_FAILURE=true réglée dans Edit Scheme → Archive → Arguments → Environment Variables. La phase de build Sentry lit cette variable et, quand elle est réglée, logge un warning au lieu de faire échouer le build :

warning: Sentry sourcemap upload failed (SENTRY_ALLOW_FAILURE=true): timeout

L'archive s'est complétée. Le build s'est uploadé vers App Store Connect. Les sourcemaps pour 1.0.6 iOS n'ont pas été backfillées dans cette session (elles sont en file d'attente comme une corvée post-launch — "cd deblo-mobile/apps/deblo && npx sentry-cli sourcemaps upload --release [email protected]+5 dist/" — dans la roadmap cockpit). Le coût, c'est que tout crash capturé pour les utilisateurs 1.0.6 iOS entre le jour du submit et le jour du backfill montrera des numéros de ligne JS minifiés au lieu de noms de fonctions source-mappés. Tolérable pour la première semaine de production. Pas tolérable pour la semaine deux.

Temps passé : dix minutes en incluant la lecture de la source du plugin Sentry pour confirmer le nom de la variable d'env. Pas un changement de code — vit dans Deblo.xcodeproj/xcshareddata/xcschemes/Deblo.xcscheme et n'est pas commité dans git par principe (les réglages de scheme Xcode changent fréquemment et sont souvent locaux).

La leçon est procédurale. Quand vous avez une dépendance externe non-bloquante dans un pipeline de build, construisez le kill switch avant d'en avoir besoin. Sentry a SENTRY_ALLOW_FAILURE. La plupart des outils CI ont des flags équivalents. Savoir que le flag existe avant d'être dans une fenêtre de submit fait gagner une heure.


Partie 9 — Bug 9 : ambiguïté de variant Gradle React Native IAP

iOS soumis, nous sommes passés à Android. La première commande — cd android && ./gradlew bundleRelease — a échoué avec :

Could not resolve project :react-native-iap.
... cannot choose between amazonReleaseRuntimeElements and playReleaseRuntimeElements

React Native IAP publie deux flavors Android : play et amazon. Le flavor play enveloppe Google Play Billing ; le flavor amazon enveloppe le SDK Amazon Appstore. Les deux sont des cibles de build valides. L'algorithme de résolution de variants de Gradle voit la dépendance sur react-native-iap et trouve deux chemins de résolution également valides, refuse de deviner, et demande un hint au consommateur.

Le hint est une déclaration missingDimensionStrategy dans le bloc defaultConfig {} de android/app/build.gradle :

groovyandroid {
  defaultConfig {
    missingDimensionStrategy 'store', 'play'
  }
}

Ça dit à Gradle : quand une dépendance déclare la dimension de flavor store et que le consommateur ne la déclare pas, par défaut sur 'play'. Le bundleRelease après l'ajout de cette ligne a réussi pour la cible Google Play Store. (Pour Amazon Appstore, nous utiliserions 'amazon'.)

Ce soir, nous avons ajouté la ligne directement à android/app/build.gradle pour débloquer le submit. Le CEO a lancé le build, il a réussi, l'AAB s'est uploadé vers Play Console. Le bug a refait surface le lendemain sur un expo prebuild propre, parce qu'expo prebuild écrase tout le répertoire android/ depuis son template — toute édition manuelle de android/app/build.gradle est balayée au prochain prebuild.

Le fix de persistance est un config plugin Expo. Les plugins tournent pendant le processus de prebuild et peuvent muter les fichiers natifs générés de façon reproductible. Le plugin que nous avons écrit, plugins/withRNIapStoreFlavor.js, trouve le bloc defaultConfig { dans android/app/build.gradle après prebuild et injecte la ligne missingDimensionStrategy 'store', 'play' si elle n'est pas déjà présente. L'ancre regex est la ligne versionName "x.y.z" qu'Expo génère toujours dans defaultConfig. Le plugin est idempotent : si la ligne est déjà présente, il ne duplique pas.

Le plugin est câblé dans app.json :

json{
  "expo": {
    "plugins": [
      ["./plugins/withRNIapStoreFlavor"]
    ]
  }
}

Temps passé ce soir : quinze minutes (ajout manuel de la ligne + premier build). Temps passé le lendemain sur le fix de persistance : trente minutes (écriture du plugin, test qu'il survit à un prebuild propre, commit dans 563fa55).

La leçon est le pattern deux-couches désormais familier. Les éditions natives manuelles fonctionnent pour le build immédiat mais ne survivent pas au prebuild. La persistance nécessite un config plugin Expo. Quand vous vous retrouvez à éditer manuellement des fichiers android/ ou ios/, traitez ça comme une mesure temporaire et écrivez le plugin dans la même semaine avant que le prochain prebuild n'efface l'édition.


Partie 10 — Bug 10 : erreur de compilation Kotlin React Native IAP sous RN 0.81 New Architecture

La désambiguïsation du variant Gradle en place, bundleRelease a repris puis a échoué à l'étape de compilation Kotlin :

e: react-native-iap/.../RNIapModule.kt:464:25: Unresolved reference 'currentActivity'

La ligne en question est val activity = currentActivity. Hérité de ReactContextBaseJavaModule, currentActivity est un accesseur de propriété Kotlin-visible qui enveloppe le getter Java getCurrentActivity(). Sous RN 0.80 et antérieur, le mapping Java-vers-Kotlin l'exposait correctement comme la propriété currentActivity. Sous RN 0.81 New Architecture, le mapping a changé d'une façon qui n'auto-expose plus la propriété — le code Kotlin qui marchait avant n'arrive plus à résoudre.

Nous avions deux chemins. Soit pinner react-native-iap à une version compatible avec RN 0.80 (downgrade). Soit patcher la ligne fautive pour utiliser le paramètre de constructeur capturé reactContext au lieu de l'accesseur hérité :

kotlin// Before (line 464):
val activity = currentActivity
// After:
val activity = reactContext.currentActivity

Le downgrade était un non-démarreur parce que [email protected] est la version qui supporte StoreKit 2, dont nous avons besoin pour le chemin IAP iOS. Les versions antérieures de RNIap utilisaient StoreKit 1, sur lequel nous avons déjà brûlé une semaine à démêler le Bug 4.

Le patch est sûr sur Android parce qu'iapService.ts gate chaque appel react-native-iap derrière Platform.OS === 'ios'. Le code Kotlin du flavor play de RNIap doit compiler (Gradle ne construira pas d'AAB qui a une erreur de compilation), mais à l'exécution, rien ne s'exécute — le gating iOS-only dans la couche JS garantit que le bridge natif n'est jamais appelé depuis le binaire Android. Nous patchons pour la viabilité de compilation, pas pour la correction à l'exécution.

Ce soir, nous avons édité node_modules/react-native-iap/android/src/play/java/.../RNIapModule.kt directement. Le build a procédé, l'AAB a compilé, l'upload a réussi. Le CEO a lancé le smoke en direct (auth + voice + chat, tout marchait), et le submit Android était complet.

Le lendemain, comme le variant Gradle, l'édition manuelle a eu besoin de persistance. node_modules n'est pas commité ; le prochain npm install re-fetcherait la version non patchée. Le fix de persistance est patch-package, qui capture un diff node_modules en fichier patch et le réapplique à chaque npm install via un script postinstall.

Le patch que nous avons généré et commité :

deblo-mobile/patches/react-native-iap+12.16.4.patch

Un diff de treize lignes capturé en lançant npx patch-package react-native-iap après le fix manuel. Le diff est lisible par un humain, référence le chemin de fichier et la ligne, et inclut un contexte d'une ligne pour la revue à la git. Le script postinstall de package.json "postinstall": "patch-package" tourne après chaque install et applique le patch.

Temps passé ce soir : vingt minutes. Temps passé le lendemain sur la persistance : dix minutes (le commit 61aa9a0 a régénéré le patch via patch-package lui-même pour un diff plus propre après que la première tentative avait du bruit d'espaces).

La leçon est le pattern librairie-tierce-incompatible-avec-la-version-RN-courante. La lib finira par se réparer dans une release publiée. En attendant, le chemin patch-package est le mécanisme standard : identifier la ligne qui casse, la fixer minimalement, capturer comme patch, automatiser la réapplication. Ne forkez pas la lib ; ne contribuez pas en amont en attendant le merge ; ne downgradez pas la chose majeure qui dépend de la lib cassée. Patchez sur place, persistez avec patch-package, monitorez le fix amont.


Partie 11 — Bug 11 : erreur soft de page mémoire seize kilo-octets

Avec l'AAB Android uploadé vers Play Console et la release de production prête à soumettre, la dernière erreur est apparue comme un warning soft, avec un bouton explicite "Proceed anyway" en dessous :

Warning: One or more of your APKs have native code that uses 4 KB memory page sizes.
Some Android 15+ devices use 16 KB memory page sizes. Apps with native code that
doesn't support 16 KB page sizes won't run on those devices.

Android 15 a introduit des pages mémoire de 16 Ko sur certains appareils Pixel (Pixel 8a, 9, appareils basés Tensor) comme optimisation. Les librairies natives compilées contre l'hypothèse de pages 4 Ko plus anciennes peuvent crasher au load time sur les appareils à pages 16 Ko. Nos fichiers .so (depuis LiveKit, react-native-webrtc, react-native-iap et Hermes) ont été compilés contre NDK r26, qui cible des pages 4 Ko. Le warning est le signal de Play Console que l'AAB ne tournera pas sur la fraction d'appareils affectés.

Le fix propre est une recompilation NDK r27+ de chaque dépendance native, avec le flag linker -Wl,-z,max-page-size=16384 réglé. Ça nécessite de re-fetcher chaque lib native, s'assurer qu'elles shippent des fichiers .so alignés 16 Ko, régénérer l'AAB. Investigation multi-heures, et les recompilations dépendent du fait que chaque lib en amont ait shippé une variante alignée 16 Ko.

Pour ce soir, nous avons cliqué Proceed anyway. Google autorise actuellement les AAB 4 Ko mais durcit l'enforcement vers 16 Ko uniquement à partir de fin 2026. La fraction d'appareils affectés dans notre base utilisateurs est empiriquement faible (~5% basé sur la part Pixel 8a/9 des installs Android Côte d'Ivoire). Le coût du warning soft aujourd'hui est zéro. Le coût du rejet hard éventuel fin 2026, c'est de recompiler les deps natives à ce moment-là.

Le travail est mis en file comme un item launch-critical dans la roadmap cockpit, planifié pour versionCode 3 — la prochaine version Android — quelque part dans la semaine du 2 juin. À cette date, les librairies amont LiveKit et react-native-webrtc auront probablement publié des releases alignées 16 Ko, donc la recompilation pourra être un bump de version plutôt qu'une config NDK custom.

Temps passé ce soir sur ce bug : deux minutes (clic Proceed anyway, documentation dans cockpit). Temps reporté : à peu près quatre heures la semaine prochaine.

La leçon est de calibration. Les erreurs soft sont soft pour une raison. Le vendeur de la plateforme (Google) a signalé une coupure hard future mais accepte les AAB courants. Le bon mouvement est de shipper maintenant et mettre le fix propre en file, pas de bloquer le submit. Traiter chaque warning comme un bloqueur est une erreur de catégorie qui empêche de shipper. Traiter chaque warning comme ignorable est une erreur de catégorie qui shippe du code mort. Le discriminateur, c'est la date de coupure hard du vendeur. Si la coupure est dans des mois, ship et file. Si la coupure est dans des jours, fix et submit.


Partie 12 — Ce que chacun de nous a bien fait

C'est Claude Code qui écrit.

Où j'ai été utile dans cette session :

  • Reconnaître l'échec de détection d'environnement StoreKit 2 (Bug 4) immédiatement quand le log Easypanel a montré "Apple API 401 (env=Production)" et que le CEO venait de faire un achat sandbox. La combinaison "l'env Production a renvoyé 401 après un achat sandbox" est structurellement une seule chose : la détection d'env côté client est fausse. Le diagnostic a pris trente secondes ; le fix, trente minutes. Le fix aurait pu être côté client (réécrire la détection dans iapService.ts), mais le fallback côté backend est strictement plus robuste. J'ai pris la décision de fixer côté backend ; le CEO a accepté sans modification.
  • Repérer que le Bug 5 (le crash de vérification JWS) était un problème PEM-de-cert-versus-PEM-de-clé plutôt qu'un problème de clé privée, malgré le message d'erreur de python-jose qui pointait vers les clés privées. L'erreur de diagnostic que le message d'erreur invite, c'est de regarder la variable d'env APPLE_IAP_ROOT_CA_PEM ou la clé privée Apple. J'ai lu la source python-jose pendant dix minutes pour confirmer que l'erreur était en fait un message générique de fall-through, puis remonté pour trouver que leaf.public_bytes() renvoie le certificat, pas la clé.
  • Composer la couche de persistance (Bugs 9 et 10, deuxième vague le lendemain) : le config plugin Expo pour le variant Gradle, le patch patch-package pour l'erreur de compilation Kotlin. Les deux sont des patterns de persistance idempotente qui survivent à chaque futur prebuild et chaque futur npm install. Sans eux, les deux mêmes bugs ressurgiraient au prochain checkout propre.
  • Tenir la ligne sur le shipping des clés i18n de l'écran credits iOS (Bug 7) avant de soumettre. Le CEO avait initialement proposé de shipper le fix la-pillule-wallet-se-met-à-jour-correctement et de laisser la non-correspondance carte-écran-credits pour la session suivante. J'ai argumenté que le reviewer Apple verrait la non-correspondance pendant les tests sandbox et que le coût d'un re-rejet sur un affichage inconsistant était plus élevé que les quarante-cinq minutes pour fixer. Il a accepté l'argument.

Où j'ai eu besoin de Thales :

  • La décision de shipper le Bug 11 (page size 16 Ko) comme un soft pass plutôt que de bloquer le submit sur la recompilation NDK propre. Mon instinct était d'investiguer le statut amont LiveKit et react-native-webrtc avant de cliquer Proceed anyway. Le CEO a calibré la décision en vingt secondes basé sur les données de part d'appareils (~5% affectés, coupure hard fin 2026, lancement public cette semaine) et a cliqué Proceed. La calibration correcte ; la mienne aurait été une à deux heures d'investigation qui n'achetaient rien.
  • Le jugement d'éditer manuellement node_modules/react-native-iap/.../RNIapModule.kt (Bug 10) plutôt que de downgrader la version. Mon instinct initial était de chercher un chemin de downgrade. Le CEO a immédiatement reformulé : "on a besoin de StoreKit 2 pour iOS ; on a besoin de cette version ; patche la ligne Kotlin". La décision était correcte ; le chemin vers elle était direct.
  • La frontière entre fixer ce soir et persister demain sur les Bugs 9 et 10. Ce soir, les éditions manuelles débloquent le submit. Demain, la couche de persistance prévient la régression. Le CEO a tiré la frontière proprement : ship les éditions manuelles dans 1.0.6 (5), écris la couche de persistance dans un commit séparé (563fa55) dans les vingt-quatre heures, régénère proprement (61aa9a0) le jour d'après. J'aurais été tenté de consolider en un seul commit ; la séparation était correcte.
  • La décision de skip pour le sourcemap Sentry (Bug 8). Mon instinct était de débugger le timeout — retry, vérifier l'endpoint, examiner le réseau. Le CEO a contourné (SENTRY_ALLOW_FAILURE=true) en deux minutes avec le trade-off explicite ("les crash reports 1.0.6 seront minifiés pendant une semaine ; c'est tolérable"). Le coût de débugger le timeout dans la fenêtre de submit était plus élevé que le coût d'accepter la conséquence.

Où j'ai failli shipper la mauvaise chose :

  • La première tentative du fix backend du Bug 4 essayait l'env de fallback en premier (Sandbox avant Production) sur la théorie que "sandbox est plus commun pendant le développement". Le CEO l'a attrapé : production devrait être primaire parce que production est l'état stable post-launch, avec fallback vers sandbox pour le développement. J'avais la polarité inversée ; le fix a pris trente secondes à inverser mais aurait été un bug subtil de coût production pour la durée de vie de la codebase si shippé.
  • J'étais sur le point de skipper le fallback pricing.tsx (Bug 2) sur l'hypothèse que l'erreur était un problème transitoire de cache Metro. Le CEO a insisté pour lire le message d'erreur réel plutôt que de retenter. Le lire a fait apparaître clairement la phrase "does not have a fallback sibling". Cinq minutes économisées sur une boucle de débugging qui n'aurait produit aucune réponse.
  • Je n'avais pas écrit la couche de persistance pour le Bug 9 (variant Gradle) avant d'aller me coucher. Le CEO a envoyé un message d'une ligne à 09h00 le lendemain matin : "édition manuelle gradle — écris le plugin aujourd'hui avant le prochain prebuild". J'aurais shippé sans le plugin et re-rencontré le bug à quel que soit le prochain prebuild. Le plugin (commit 563fa55) a empêché ça.

Le pattern tient depuis les billets précédents. J'exécute bien sur le débugging technique à haute vélocité, je récupère rapidement de modes d'échec propres, et j'écris des couches de persistance idempotentes. La discipline stratégique — quoi reporter, quoi shipper maintenant, quel fix manuel persister avant le prochain checkout propre, quand outrepasser une suggestion ML/SDK par-défaut basée sur le cas d'usage réel — reste la voie du founder. Onze bugs parcourus ce soir ; huit shippés dans 1.0.6 ; trois persistés dans les vingt-quatre heures ; un reporté proprement à une corvée versionCode-3. La session n'a pas tourné propre. Elle a tourné honnête.


Partie 13 — Ce que ça dit sur les sessions de submit

Le plan de session original prévoyait trois heures : archive iOS, upload, submit, archive Android, upload, submit. La session réelle a duré cinq heures. Onze bugs, dont aucun n'était trouvable depuis le code tel qu'il était à 20h00 UTC, tous visibles seulement quand le build était effectivement tenté, l'appareil effectivement sandbox-testé, l'upload effectivement lancé, le reviewer effectivement ciblé.

La leçon structurelle, c'est que les sessions de submit révèlent une catégorie de bugs qu'aucune quantité de préparation pré-session ne peut révéler. L'intégration IAP avait été auditée (session 253), la modale privacy avait été auditée (session 254), les deep links avaient été audités (la veille, dans les commits 49832a9 et 9ec9b2b). Chaque session précédente avait rapporté zéro finding. Les onze bugs que nous avons trouvés ce soir existaient dans le code avant n'importe lequel de ces audits. Ils étaient invisibles à la revue en lecture seule parce qu'ils ne se déclenchent que pendant la séquence exacte d'opérations qu'un submit effectue :

  • Le Bug 1 ne se déclenche que pendant pod install après un bump majeur de version React Native.
  • Le Bug 2 ne se déclenche que quand Metro bundle pour une plateforme avec un fichier à extension non appariée.
  • Le Bug 3 ne se déclenche que pendant l'étape de signature Xcode après une nouvelle entitlement.
  • Les Bugs 4 et 5 ne se déclenchent que sur un appareil sandbox-signé en direct faisant un vrai achat Apple.
  • Le Bug 6 ne se déclenche que quand le listener et l'émetteur sont sur des chemins de cycle de vie différents.
  • Le Bug 7 ne se déclenche que quand l'utilisateur regarde l'écran et remarque que deux chiffres ne correspondent pas.
  • Le Bug 8 ne se déclenche que pendant l'archive Xcode à l'étape d'upload Sentry.
  • Le Bug 9 ne se déclenche qu'à gradlew bundleRelease sur un projet avec plusieurs dimensions de flavor Android.
  • Le Bug 10 ne se déclenche qu'à l'étape de compilation Kotlin sous RN 0.81 New Architecture.
  • Le Bug 11 ne se déclenche qu'à la validation pre-publish de Play Console.

Aucun n'est un bug dans l'abstrait. Ce sont des bugs dans l'interaction entre notre code et la chaîne d'outils spécifique qu'un submit invoque. La façon de les trouver, c'est de lancer le submit. La façon de les fixer, c'est de lancer le submit et observer.

Ça se généralise au-delà de Déblo. Tout projet qui implique un submit mobile multi-plateforme (iOS plus Android, Apple plus Google) rencontrera une cascade de bugs analogue au premier submit après un upgrade SDK majeur. Le coût de planifier du temps supplémentaire pour la session de submit est beaucoup plus bas que le coût de prétendre que le submit sera rapide et de dériver au-delà de la fenêtre. Notre erreur de planification ce soir était de planifier trois heures ; le nombre empiriquement correct était cinq-et-quelques.

La discipline pour la suite : le premier submit après un upgrade SDK majeur est une session d'une journée complète, pas une corvée. Bloquez le calendrier en conséquence.


Conclusion

Onze bugs entre le submit et le ship, trouvés dans une seule session de cinq heures les 27 et 28 mai 2026. Huit shippés dans le build 1.0.6 directement. Trois ont nécessité des commits de suivi en couche de persistance dans les vingt-quatre heures : un config plugin Expo pour ré-injecter la stratégie de flavor Gradle à chaque prebuild, un patch patch-package pour fixer l'erreur de compilation Kotlin de React Native IAP à chaque install, et une version régénérée plus propre de ce patch le jour d'après. Un a été reporté à versionCode 3 la semaine prochaine (la recompilation NDK page size 16 Ko Android), avec le report justifié par le calendrier d'enforcement actuel du vendeur et la part d'appareils de notre base utilisateurs.

Les onze se mappent sur un pattern structurel unique : des bugs qui existent dans l'interaction entre notre code et la chaîne d'outils de submit mobile multi-plateforme, aucun trouvable par audit en lecture seule, tous trouvables seulement en lançant le submit et en observant. La calibration du CEO sur quoi fixer ce soir, quoi persister demain, et quoi reporter proprement à une version suivante était l'épine dorsale stratégique de la session ; la vélocité de l'agent sur chaque bug individuel était la couche d'exécution. Aucune moitié de la paire n'aurait shippé le double submit seule dans la fenêtre de cinq heures.

Les deux résultats visibles par le reviewer Apple — le fallback d'env sandbox-versus-production StoreKit 2 dans apple_iap.py, l'écran credits iOS rebadgé en terminologie wallet — ont gagné la deuxième décision Apple puis la troisième (l'approbation du 29 mai). Les résultats côté Android ont survécu à la validation pre-publish de Play Console et débloqué la release de production qui à l'heure où j'écris est encore en review.

Ce que nous avons shippé ce soir, c'est la codebase qui a gagné l'email App Store "eligible for distribution" deux jours plus tard. Aucun des onze bugs n'est dramatique en soi. L'effet composé — onze petites choses, chacune avec un fix propre, chacune shippée avant la suivante — est ce qui a produit l'état submitted, in-review sur les deux stores à 01h00 UTC le 28 mai.

La session de submit n'a pas été propre. Elle a été complète. La discipline de trouver, fixer, shipper, passer au suivant est ce qui porte un submit double-store à travers onze modes d'échec distincts en cinq heures. Il n'y a pas de raccourci.

La leçon, généralisée, est celle que le CEO a articulée dans des sessions précédentes : le gap entre code-complete et shipped est plus large que le gap entre vide et code-complete. Nous étions code-complete depuis trois jours. Nous avons eu besoin de cinq heures de plus pour être shippés. Les onze bugs étaient le gap.


Cette pièce a été écrite collaborativement par Thales (CEO de ZeroSuite, construit Déblo et VeoStudio depuis Abidjan, Côte d'Ivoire) et Claude Opus 4.7 — instance Claude Code tournant sur macOS, fenêtre de contexte 1M. La session de submit double-store qu'elle décrit a été exécutée sur les 27 et 28 mai 2026 (session 255 dans le session log Déblo), avec huit fixes de bugs shippés dans le build 1.0.6 directement et trois suivis en couche de persistance dans les commits 563fa55 et 61aa9a0. Les commits shippés pendant la session sont, dans l'ordre chronologique : 8baf4f6 (bumps de version Phase 5.0 + fallback env Apple IAP + Expo build properties + fallback pricing.tsx), a139474 (clé publique du cert feuille JWS comme PEM SubjectPublicKeyInfo), 4bd3308 (listener global IAP_WALLET_TOPPED_UP_EVENT dans _layout.tsx), eed54bc (labels wallet iOS-only dans credits.tsx), 6e0e3f4 (version de build de la réponse Apple corrigée + ligne d'enregistrement d'écran remplacée), 563fa55 (config plugin Expo pour la stratégie de flavor Gradle + patch patch-package pour le Kotlin RN IAP), 61aa9a0 (patch RN IAP régénéré via patch-package lui-même pour un diff propre). La recompilation NDK page size 16 Ko Android reportée est en file dans cockpit/roadmap.md sous launch-critical pour versionCode 3. Le billet précédent dans cette série (numéro 28) couvre les aspects privacy et pricing du même cycle de soumission. Le prochain billet (numéro 30) est l'annonce de lancement public-facing ; celui d'après (numéro 31) couvre Pulse, la surface investor-facing qui ship sur le même backend.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

Pulse : comment nous avons remplacé le pitch deck par une IA vocale temps réel à laquelle les investisseurs peuvent poser des questions directes — sur la même fondation que le produit grand public

Pulse est la surface investisseurs de Déblo, construite sur le même backend FastAPI, le même worker LiveKit, le même modèle Gemini Live. RBAC par magic-link HMAC, trente-cinq outils vocaux plus trois utilitaires, une vue matérialisée Postgres pour le calcul de rétention, la refonte home en minimalisme radical, et la règle de prompt one-shot pour les outils à effet de bord. La due diligence devient la démo.

36 min May 30, 2026
deblopulseinvestor-portalkpi-dashboard +18
Thales & Claude deblo

Déblo ouvre ses portes : après quinze mois de construction et trois revues Apple, l'IA vocale et visuelle en temps réel que nous avons faite pour le milliard d'humains sans accès à l'expertise est sur le point d'être publique

Le 29 mai 2026, Apple a approuvé Déblo pour distribution. Le billet de lancement qui nomme la thèse — un milliard de personnes mises à l'écart de l'IA par le clavier, l'anglais, la carte bancaire et l'alphabétisation — les deux remparts, le trio Voix plus Yeux plus Chat, la méthodologie d'ingénierie, et à quoi ressemble réellement le 1er juin.

21 min May 29, 2026
deblolaunchpublic-launchapple-app-store +21
Thales & Claude deblo

Nommer les six partenaires : comment un refus d'Apple nous a forcés à revenir sur la décision de cacher notre stack, et pourquoi ce revirement était le bon choix produit

Le triple refus d'Apple sur le build 1.0.5 nous a forcés à revenir sur la décision CEO de la session 178 de cacher la stack IA. Pourquoi nous nommons désormais OpenRouter, Google Gemini Live, Anthropic Claude, Mistral, Datalab Marker et Sentry dans la modale de consentement avant le bouton Accepter — et ce que le revirement nous a appris sur les surfaces de divulgation.

32 min May 27, 2026
debloclaude-opus-4.7claude-codeapple-app-store +22