Building for release¶
pn run is for iterating on a device or simulator. When you're ready to
ship, pn build produces standalone, distributable artifacts:
Pass --debug to build the debug variant instead (a debug APK on
Android; a Simulator .app on iOS):
pn build reads identity, permissions, assets, and signing from
pythonnative.toml. Run pn doctor
first to confirm the toolchain is ready.
Check first: pn doctor¶
pn doctor validates pythonnative.toml and checks the platform
toolchain: Android needs JDK 17 through 23, the configured SDK (36 by
default), NDK 28.2.13676358, and adb. iOS needs macOS, Xcode 26 or
later, and simctl. The doctor also reports signing, lockfile, and
privacy-manifest configuration. It
exits non-zero on anything that will block a build, so you can gate CI
on it.
Frozen dependencies¶
Release builds with Python dependencies require a current pn.lock covering
all build targets. Resolve and review the lock before building:
Commit the lock with your application. Changing requirements or replacing a local wheel requires refreshing it. Releases reject missing or stale locks; development builds can still resolve unlocked requirements. Native extension manifests pin SwiftPM and Maven dependencies to exact versions.
Android¶
pn build android runs assembleRelease and bundleRelease, producing
both an APK and a Play-ready AAB. Artifacts are reported at the end of
the build and live under the staged project:
build/android/android_template/app/build/outputs/apk/release/app-release.apk
build/android/android_template/app/build/outputs/bundle/release/app-release.aab
Toolchain and packaged binaries¶
The template uses compile/target SDK 36, Gradle 8.13, Android Gradle Plugin 8.11.1, Kotlin 2.2.21, and NDK 28.2.13676358. Release builds reject a target SDK below 36. These are PythonNative's supported build defaults, independent of store submission deadlines.
After Gradle builds, PythonNative inspects the APK and AAB. Every packaged 64-bit ELF library must have 16 KB aligned LOAD segments, including Python extensions inside Chaquopy assets. Uncompressed APK libraries must also be ZIP aligned, and the AAB must request compatible generated APK alignment. An error names the offending dependency. Rebuild or replace that dependency; upgrading your app's NDK can't repair a prebuilt wheel. See Android's 16 KB page-size guidance.
Signing¶
Without signing configured, Gradle emits an unsigned release APK
(app-release-unsigned.apk), fine for inspection, not for the store.
To produce signed artifacts, create a keystore once:
keytool -genkeypair -v -keystore release.keystore \
-alias myapp -keyalg RSA -keysize 2048 -validity 10000
…then point pythonnative.toml at it:
[android.signing]
keystore = "release.keystore"
key_alias = "myapp"
# store_password_env / key_password_env default to
# PN_ANDROID_KEYSTORE_PASSWORD / PN_ANDROID_KEY_PASSWORD
Passwords are never stored in the config, only the names of the environment variables that hold them. Provide the secrets at build time:
When signing is configured, pn injects a Gradle signingConfig into
the release build so the resulting APK/AAB are signed and upload-ready.
Keep keystores out of git
Commit neither the keystore nor the passwords. Store the keystore as a CI secret/file and inject the passwords via environment variables.
iOS¶
pn build ios archives the app with xcodebuild archive and exports a
signed .ipa with xcodebuild -exportArchive. The Python framework,
standard library, and app code are installed and signed during the
Xcode build itself, so the archive needs no post-processing. Outputs:
To send the build straight to App Store Connect instead of exporting a
local .ipa, set export_method = "app-store" and pass --upload:
The upload uses the App Store Connect credentials Xcode has stored; in
CI, provide an ASC API key through Xcode's standard mechanisms or
upload the exported .ipa with xcrun altool/Transporter instead.
Privacy manifests¶
The template includes an app PrivacyInfo.xcprivacy. Supply your app's own
manifest with a path relative to the project:
PythonNativeKit ships its own resource-bundle manifest for preferences and cache-file timestamps. Archive validation checks that the app and runtime manifests are present and structurally valid before export or upload. It also parses manifests supplied by bundled dependencies. This check doesn't infer your application's data collection, tracking, or required-reason API usage. Declare those from your actual behavior using Apple's privacy-manifest documentation.
Bytecode-only bundles¶
Release builds byte-compile your app/ sources and every bundled
package to .pyc and drop the .py files, which shrinks the bundle
and avoids shipping plain-text source. Because bytecode is
version-specific, this requires the Python running pn to match
app.python_version; otherwise pn prints a notice and ships .py
sources instead. Debug builds (pn run, pn build --debug) keep the
.py files on both platforms so tracebacks show source lines and the
dev client can tell which sources the app already has.
Signing¶
Set your Apple Developer Team ID and an export method:
[ios]
development_team = "ABCDE12345"
[ios.signing]
export_method = "app-store" # development | ad-hoc | app-store | enterprise
provisioning_profile = "My App Distribution"
development_team drives signing during archive; export_method and
the optional provisioning_profile are written into the
exportOptions.plist that xcodebuild -exportArchive consumes. If
export fails, the error points you back at [ios.signing].
Embedded Python runtime¶
iOS has no system Python, so PythonNative embeds CPython from the
Python-Apple-support
project. On the first iOS build, pn downloads the pinned, checksum-
verified runtime for your app.python_version and caches it under
build/ios/ios_runtime/. The Xcode build links Python.xcframework,
installs the standard library, and bundles your app/ sources, the
pythonnative package, and the [requirements].packages resolved for
the SDK being built (device or Simulator), binary wheels included.
Pinned, verified runtimes exist for Python 3.13 and 3.14; set
python_version in [app] accordingly. Unpinned versions are
rejected rather than fetched unverified. See
PyPI packages for how requirements are resolved
per target.
App icon and splash¶
Provide a single high-resolution source image and PythonNative renders every per-platform, per-density variant at build time:
- iOS: a universal
AppIcon.appiconset(Xcode resizes the rest) and aSplashimage set referenced by the generated launch screen. - Android:
mipmap-*launcher icons at every density plus a round variant, and a centered icon for the Android 12+ splash screen.
Image processing needs Pillow, an optional dependency:
If Pillow isn't installed the build still succeeds; it just keeps the
template's default assets. pn doctor reports whether Pillow is
available.
Versioning¶
Two fields in [app] drive the store-visible version and the internal
build number:
[app]
version = "1.2.0" # CFBundleShortVersionString / versionName
build = 7 # CFBundleVersion / versionCode (bump every upload)
Both stores reject a new upload that reuses an existing build number, so
increment build for each submission.
Continuous integration¶
A typical release job:
pip install 'pythonnative[build]'
pn doctor android # fail fast on a misconfigured runner
export PN_ANDROID_KEYSTORE_PASSWORD=$KEYSTORE_PW
export PN_ANDROID_KEY_PASSWORD=$KEY_PW
pn build android
pn app-id android / pn app-id ios print the resolved id, which is
handy for downstream steps (uploaders, smoke tests) that need it without
re-parsing the config.
Prepare without building¶
To hand off to Android Studio or Xcode instead of building from the CLI, stage and configure the native project without compiling:
This writes a fully configured project (identity, permissions, icons,
relocated Android package) under build/, which you can open and build
with the native IDE, useful for debugging signing or build issues with
the platform's own tooling.