App Store & Google Play: The Complete Publication Guide for Developers
Everything developers need to publish apps to Google Play and App Store: signing, tracks, review rules, ASO, CI/CD with fastlane, staged rollouts, and production incidents.
Shipping a mobile app is not the same as deploying a web service. When you push to a server, the change is live in seconds. When you submit to Google Play or the App Store, you enter a system with its own rules, its own timelines, its own rejection criteria, and its own audience — the store itself, which decides whether your app reaches users at all.
Most developers learn this the hard way: a binary that works perfectly on every device in the office gets rejected for a missing privacy declaration, or ships with the wrong signing configuration, or passes review but rolls out to users with a crash rate that triggers an automatic halt. The submission process is not just packaging — it is a series of decisions that affect discoverability, stability guarantees, legal compliance, and release velocity.
This guide covers both stores end to end — accounts, signing, release tracks, store listings, review rules, automation, and what to do when something goes wrong after the release is live.
Before You Submit: The Business Layer
Both Google and Apple require a registered developer account before you can submit anything. These are not technical accounts — they are business agreements with annual fees and legal obligations.
Google Play Console — $25 one-time registration fee per developer account. The account can publish unlimited apps. You agree to the Developer Distribution Agreement, which includes content policies, privacy requirements, and API usage restrictions. Registration requires a Google account, payment method, and personal or business identity verification.
Apple Developer Program — $99 per year per account (individual or organization). Organization accounts require a D-U-N-S number from Dun & Bradstreet, which takes 5–14 business days to obtain. This is a real legal entity requirement — Apple verifies that your organization exists. Plan for this lead time before your first submission.
Both accounts can have multiple team members with role-based permissions. Set this up from day one: having a single person as the sole account holder creates a single point of failure for your entire release pipeline.
Google Play: End to End
App Signing
Android apps must be signed with a private key before they can be installed. Google Play uses Play App Signing — Google holds the app signing key, and you upload your app signed with an upload key. This separation is important: if your upload key is ever compromised, you can rotate it without losing the app.
Generate an upload keystore with keytool:
keytool -genkey -v \
-keystore upload-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias upload
Store the keystore file and passwords somewhere secure — a password manager or a secrets vault, not the repository. If you lose the keystore before enrolling in Play App Signing, you cannot update your app. Ever.
In android/app/build.gradle, configure the signing:
android {
signingConfigs {
release {
storeFile file(System.getenv("KEYSTORE_PATH") ?: "../upload-keystore.jks")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
Never hardcode credentials in build.gradle. Use environment variables, which CI/CD systems can inject securely.
AAB vs APK
Google Play requires Android App Bundle (AAB) format for new apps. APK is only for direct distribution outside the store.
# Flutter
flutter build appbundle --release
# React Native
cd android && ./gradlew bundleRelease
# Native Android (Gradle)
./gradlew bundleRelease
The AAB lets Google generate optimized APKs per device configuration — smaller downloads, faster installs. The output file is typically at build/app/outputs/bundle/release/app-release.aab.
Release Tracks
Google Play has four tracks. Use them in order — do not jump straight to production.
Internal testing — up to 100 testers, invited by email. No review required. Available within minutes of upload. Use this for every build during development. Configure it as your default CI output.
Closed testing (Alpha) — larger tester groups, still invitation-based. Still no review for most apps. Use this for QA sign-off.
Open testing (Beta) — any user can opt in. Apps here go through a basic automated review. Use this for external beta feedback and final pre-production validation.
Production — full review. Available to all users. You control rollout percentage.
A realistic release flow:
Feature complete → Internal build → QA on Internal track
→ QA passes → Promote to Closed track
→ External beta feedback → Promote to Open track (optional)
→ Final sign-off → Submit to Production (10% rollout)
→ Monitor crash rate for 48h → Roll out to 100%
Versioning
In build.gradle (or pubspec.yaml for Flutter):
android {
defaultConfig {
versionCode 42 // integer, must increase with every upload
versionName "2.1.0" // human-readable, shown in the store
}
}
versionCode is what Google uses internally. It must strictly increase — you cannot reuse or decrement it. A common scheme is to compute it from the version components:
// 2.1.0 → 2001000, 2.1.3 → 2001003
def major = 2
def minor = 1
def patch = 0
versionCode = major * 1000000 + minor * 1000 + patch
versionName = "${major}.${minor}.${patch}"
Or for CI environments, use the build number from the CI system:
flutter build appbundle --build-number=$CI_BUILD_NUMBER --build-name=2.1.0
Play Console Setup Checklist
Before your first submission, complete all of these in the Play Console:
- App content — complete the content rating questionnaire (IARC). Your answers determine the age rating shown in the store.
- Data safety section — declare every type of data your app collects and shares, how it is used, and whether it is encrypted in transit. This is a legal declaration. Get it right. Missing or inaccurate data safety declarations are a growing cause of rejections.
- Store listing — app name (max 30 characters), short description (max 80 characters), full description (max 4,000 characters), screenshots (minimum 2, maximum 8 per device type), feature graphic (1024×500px), app icon (512×512px).
- Privacy policy URL — required for any app that collects data. A real privacy policy, not a placeholder.
- Target audience — if your app targets children under 13, COPPA and GDPR-K requirements apply and trigger additional restrictions on SDKs and data collection.
Common Google Play Rejection Reasons
Permissions violations. Declaring a permission in AndroidManifest.xml that your app does not actively use will cause a rejection. Audit every <uses-permission> tag and remove anything not needed. Background location access (ACCESS_BACKGROUND_LOCATION) requires explicit justification.
Deceptive store listing. Screenshots that show features not in the current version, or descriptions that claim functionality the app does not have.
Data safety mismatch. If your app uses a third-party SDK (analytics, ads, crash reporting) that collects data, you must declare that data in the data safety section even if your own code does not collect it directly. Check every SDK’s data safety disclosure.
Policy violations in content. Hate speech, sexual content beyond the content rating, dangerous activities. Review the content policy before submission.
App Store (iOS): End to End
Certificates and Provisioning
iOS code signing is the most misunderstood part of Apple’s ecosystem. The components:
Certificate — proves your identity as a developer. Two types: Development (for running on test devices) and Distribution (for App Store submission). Generated in Xcode or on developer.apple.com.
App ID — a unique identifier for your app, matching your bundle ID (e.g., com.yourcompany.yourapp). Registered in the developer portal.
Provisioning profile — combines a certificate + app ID + (for development) a list of authorized device UDIDs. The Distribution profile for App Store does not list specific devices — it allows installation on any device via the App Store.
The simplest management approach for teams is Xcode automatic signing for development and manual signing for release builds in CI:
# In Xcode project settings
CODE_SIGN_STYLE = Automatic # development
CODE_SIGN_STYLE = Manual # release/CI
PROVISIONING_PROFILE_SPECIFIER = "AppStore Distribution 2026"
CODE_SIGN_IDENTITY = "Apple Distribution"
For CI, export the certificate as a .p12 file and the provisioning profile as a .mobileprovision file, encode them as base64, and store as CI secrets. Your pipeline decodes and installs them before building.
Building and Archiving
An App Store submission requires an archive built with the Distribution certificate.
# Xcode command line
xcodebuild archive \
-scheme YourApp \
-configuration Release \
-archivePath build/YourApp.xcarchive \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE_SPECIFIER="AppStore Distribution 2026"
# Export IPA from archive
xcodebuild -exportArchive \
-archivePath build/YourApp.xcarchive \
-exportPath build/output \
-exportOptionsPlist ExportOptions.plist
ExportOptions.plist controls the export method:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>YOUR_TEAM_ID</string>
<key>uploadBitcode</key>
<false/>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.yourcompany.yourapp</key>
<string>AppStore Distribution 2026</string>
</dict>
</dict>
</plist>
For Flutter:
flutter build ipa --release
# IPA at build/ios/ipa/YourApp.ipa
App Store Connect Setup
Bundle ID — must match exactly what is in your Xcode project. Register it in the developer portal before creating the app in App Store Connect.
Build number — CFBundleVersion in Info.plist. Must increase with every build uploaded to App Store Connect, even TestFlight builds. CFBundleShortVersionString is the user-visible version string.
# Set via agvtool
agvtool new-marketing-version 2.1.0
agvtool new-version -all 42
App information — name (max 30 characters on the store, but your bundle name can be longer), subtitle (max 30 characters, shown below the name in search), category (primary and optional secondary), content rights declaration.
App Privacy — declare every data type your app collects, the purpose, and whether it is linked to the user’s identity. Like Google’s data safety section, this covers third-party SDKs. Apple shows this as the “Privacy Nutrition Label” on the app’s store page.
Age Rating — based on a questionnaire about content: violence, sexual content, gambling, drug use, user-generated content. Answer accurately — misrepresenting age-appropriate content is grounds for removal.
Screenshots — required sizes:
- iPhone 6.9” (1320×2868 or 1290×2796) — used as default for all iPhone sizes
- iPad Pro 13” (2064×2752 or 2048×2732) — required if your app supports iPad
- Each screenshot is actually a 1242×2688px image shown in a device frame on the store
Screenshots must show actual app UI — no marketing images that do not represent the app. Apple reviewers check this.
TestFlight
TestFlight is Apple’s beta distribution platform, built into App Store Connect. Every build uploaded to App Store Connect is automatically available in TestFlight before going to review.
Internal testers — up to 100 members of your App Store Connect team. Available within minutes of upload, no review required. Builds expire after 90 days.
External testers — up to 10,000 users invited by email or a public link. Requires a basic TestFlight review (usually within 24 hours, faster than full App Store review). Use this for beta programs.
TestFlight is the right way to distribute pre-release builds to stakeholders. Do not use ad-hoc distribution or enterprise certificates for this — it violates Apple’s guidelines and those certificates are meant for internal device testing only.
App Store Review
Apple’s review process typically takes 24–48 hours for new apps and 24 hours for updates. Expedited review is available for critical bug fixes — use the request form in App Store Connect, explain the user impact, and include the specific build. Apple grants expedited review for genuine production issues, not for missed deadlines.
Common rejection reasons:
Guideline 2.1 — Performance: App Completeness. Crashes during review, placeholder content (“Lorem ipsum”), or demo/test features visible in the production build. Reviewers test your app. Crashes fail immediately.
Guideline 4.0 — Design. Apps that are too simple — a single web view of a website, a PDF reader with no additional features, or apps that replicate the functionality of a built-in iOS app without adding value.
Guideline 5.1.1 — Privacy: Data Collection. Requesting access to contacts, location, camera, microphone, or health data without a clear purpose string in Info.plist. The purpose string must explain specifically why the app needs access — “for better experience” is rejected. Be specific.
Guideline 3.1.1 — In-App Purchase. Selling digital goods or subscriptions through a payment method other than Apple’s IAP system. This includes linking to a website to buy a subscription that is then available in the app.
Guideline 1.4 — Physical Harm. Apps that provide dangerous information without appropriate warnings: medical dosage information, legal advice, financial advice. Add disclaimers.
When you receive a rejection, read the specific guideline cited. Reply in the Resolution Center with the exact change you made and where to find it in the app. Do not argue — address the specific concern and resubmit.
App Store Optimization (ASO)
ASO is how users find your app through search within the store. It directly impacts organic downloads without any advertising spend.
App name. Include your primary keyword in the name. Users search for what the app does, not what it is called. “Planify — Task Planner” ranks for both “Planify” and “task planner.” Google Play gives more weight to the name than Apple does.
Keywords field (iOS only). 100 characters, comma-separated, no spaces after commas. Apple does not show this to users — it is purely for search indexing. Do not repeat words already in your name or subtitle. Use the full 100 characters.
task,planner,todo,calendar,reminder,schedule,productivity,work,goals,habit
Description. Google Play indexes the full description for search. Front-load the most important keywords in the first three lines (visible without tapping “more”). Apple does not index the description for search — focus on conversion, not keywords.
Screenshots. The first two screenshots are shown in search results before the user taps the listing. These drive conversion more than any other asset. Make them functional explanations of the app’s core value, not decorative images.
Ratings. Both stores use average rating and review count as ranking signals. Prompt for ratings at a moment of success — after completing a task, after a positive interaction — not on launch or after an error. Use the platform’s native rating prompt (not a custom one that filters negative reviews to a form instead of the store — this violates both stores’ guidelines).
Release notes. Users read release notes before updating. Write them for users, not for your team’s internal ticket system. “Various bug fixes and performance improvements” is a missed opportunity. Describe what changed, what improved, and what is new.
Automating with Fastlane
Fastlane is the standard tool for automating mobile release pipelines. It handles building, signing, screenshot generation, metadata management, and uploading to both stores from a single Fastfile.
Install:
gem install fastlane
# or
brew install fastlane
Initialize in your project root:
fastlane init
A Fastfile for a Flutter app with both stores:
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(scheme: "Runner")
end
desc "Build and upload to TestFlight"
lane :beta do
build_number = ENV["CI_BUILD_NUMBER"] || "1"
# increment build number
increment_build_number(
build_number: build_number,
xcodeproj: "ios/Runner.xcodeproj"
)
# build
sh("flutter build ipa --release --build-number=#{build_number}")
# upload to TestFlight
upload_to_testflight(
ipa: "build/ios/ipa/Runner.ipa",
skip_waiting_for_build_processing: true
)
end
desc "Submit to App Store"
lane :release do
upload_to_app_store(
ipa: "build/ios/ipa/Runner.ipa",
skip_screenshots: false,
skip_metadata: false,
submit_for_review: false, # set true to auto-submit after upload
automatic_release: false
)
end
end
platform :android do
desc "Build and upload to Internal track"
lane :internal do
build_number = ENV["CI_BUILD_NUMBER"] || "1"
sh("flutter build appbundle --release --build-number=#{build_number}")
upload_to_play_store(
track: "internal",
aab: "build/app/outputs/bundle/release/app-release.aab",
json_key: ENV["GOOGLE_PLAY_JSON_KEY_PATH"]
)
end
desc "Promote from internal to production with staged rollout"
lane :promote_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: "0.1", # 10% staged rollout
json_key: ENV["GOOGLE_PLAY_JSON_KEY_PATH"]
)
end
end
For Google Play automation, generate a service account JSON key in the Google Cloud Console with the “Service Account” role in Play Console. Store the JSON as a CI secret.
For App Store automation, use App Store Connect API keys (not your personal Apple ID). Generate a key in App Store Connect → Users and Access → Integrations → App Store Connect API. Store the private key (.p8 file), Key ID, and Issuer ID as CI secrets.
GitHub Actions Pipeline
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.0'
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/upload-keystore.jks
- name: Build AAB
env:
KEYSTORE_PATH: upload-keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: flutter build appbundle --release --build-number=${{ github.run_number }}
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.yourcompany.yourapp
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: internal
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.29.0'
- name: Install certificate and profile
env:
CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
# create keychain
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
# install certificate
echo "$CERTIFICATE_BASE64" | base64 -d > certificate.p12
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
# install provisioning profile
echo "$PROVISIONING_PROFILE_BASE64" | base64 -d > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
- name: Build IPA
run: flutter build ipa --release --build-number=${{ github.run_number }}
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: |
xcrun altool --upload-app \
--type ios \
--file build/ios/ipa/Runner.ipa \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
Staged Rollouts and Monitoring
Google Play Staged Rollout
Never release to 100% immediately. Use staged rollouts — release to a percentage of users, monitor crash rate and ANR rate, then expand.
In Play Console, set the rollout percentage when promoting to production. You can increase or halt the rollout at any time.
Halt criteria: if the crash-free sessions rate drops below 99.5% or the ANR rate increases measurably, halt the rollout before expanding. A halted rollout does not remove the app from existing users — it stops the rollout to new users while you investigate.
Day 0: Release at 10%
Day 2: Crash rate stable → expand to 30%
Day 4: Stable → expand to 50%
Day 6: Stable → expand to 100%
With fastlane:
lane :expand_rollout do |options|
upload_to_play_store(
track: "production",
rollout: options[:percentage].to_s,
skip_upload_aab: true,
json_key: ENV["GOOGLE_PLAY_JSON_KEY_PATH"]
)
end
# fastlane expand_rollout percentage:0.5
App Store Phased Release
Apple’s equivalent is Phased Release — automatically rolls out to 1%, 2%, 5%, 10%, 20%, 50%, then 100% over 7 days. You can pause a phased release for up to 30 days.
Enable it in App Store Connect when submitting the version: Phased Release → On.
What to Monitor Post-Release
Set up monitoring before your first production release — not after something breaks.
Crash reporting. Firebase Crashlytics is the standard for both platforms. It surfaces crash-free rate, crash count by version, and symbolicated stack traces. Set alert thresholds at 0.5% crash rate.
ANR rate (Android). Application Not Responding — the Android-specific hang that occurs when the main thread is blocked for more than 5 seconds. Play Console shows this under Android Vitals. High ANR rates can cause Play to reduce your app’s visibility in search.
Core Web Vitals equivalents for mobile — Android Vitals:
- Crash rate < 1.09% (Play’s current threshold for “bad behavior” flagging)
- ANR rate < 0.47%
- Excessive wakeups, stuck partial wake locks, excessive background WiFi scans
Monitor these from day one. Play uses Android Vitals data to penalize apps in search ranking.
Emergency: Pulling a Release
Sometimes a release ships with a critical bug. The options differ between platforms.
Google Play. You cannot remove a version that is already installed on user devices. You can:
- Halt the staged rollout (stops expansion, does not remove from existing users)
- Submit an emergency update immediately — it can go through review in hours if you request it via the priority review flag
App Store. After approval, you can remove the version from sale in App Store Connect before it is downloaded by more users. Users who already have it installed keep it. Submit an expedited update immediately.
The emergency hotfix pipeline:
Detect critical issue
→ Halt rollout (Android) or Remove from sale (iOS)
→ Create hotfix branch from the release tag
→ Fix the specific issue — minimum change
→ Increment build number/version code
→ Run automated tests on the hotfix
→ Submit via CI to Internal track (Android) or TestFlight (iOS)
→ Manual smoke test on physical devices
→ Submit emergency update to production
→ Monitor for 2 hours
→ Resume/expand rollout
The key discipline: the hotfix branch touches only the broken thing. Do not include unrelated changes in an emergency release. Every additional change is a new surface for new bugs.
App Lifecycle Management
Supporting Multiple Versions
Users do not always update immediately. On Android, you can reach 85%+ of your active users within 2 weeks of a release. On iOS, update adoption is typically faster — over 90% within a few weeks for active users. But the tail persists.
Decide your minimum supported version carefully:
- Set
minSdkVersion(Android) andiOS Deployment Targetbased on your actual user analytics, not the latest available SDK - Force-update gates (showing a blocking “please update” screen) should be used only for critical security issues or API incompatibility — users abandon apps that demand updates without clear reason
- Deprecate old versions gracefully: notify in-app before disabling features, give users a timeline
Privacy and Compliance Updates
Both stores now require ongoing compliance maintenance:
- Google’s Data Safety section must be updated when you add new SDKs or change data collection practices
- Apple’s App Privacy declaration must be updated when you add new SDKs — even if your own code does not change
- GDPR / CCPA consent flows must be present if your app serves EU or California users and uses any advertising or analytics SDKs
A major SDK update (Google Analytics, Firebase, Meta SDK) often changes what data is collected. Check the SDK’s data safety documentation before each update, not just when submitting a new feature.
Publishing a mobile app is a process you learn through iteration. The first submission is always the hardest — every unknown requirement surfaces at once. By the third or fourth release cycle, the process becomes routine: sign, build, upload, monitor, expand, repeat. The developers who do it smoothly are the ones who automated the mechanical parts and internalized the review guidelines well enough that rejections are rare and recoverable when they happen.
The store is not the finish line. It is the beginning of the relationship between your app and its users. Every release is a promise about quality, privacy, and reliability. Keep it.
Tags
Related Articles
Automation Tools for Developers: Real Workflows Without AI - CLI, Scripts & Open Source
Master free automation tools for developers. Learn to automate repetitive tasks, workflows, deployments, monitoring, and operations. Build custom automation pipelines with open-source tools—no AI needed.
Clean Architecture: Building Software that Endures
A comprehensive guide to Clean Architecture explained in human language: what each layer is, how they integrate, when to use them, and why it matters for your business.
Demystifying Push Notifications: The Complete Guide
A comprehensive theoretical exploration of push notifications: how they work, the infrastructure behind them, why mobile devices handle them so well, the services involved, and the fascinating journey from simple alerts to the sophisticated notification systems we use today.