Android Play Release skill for Codex
Bundles that pass Play checks on first upload. Codex builds a signed App Bundle with secrets kept out of the repo, runs a bundled script that checks every native library for 16 KB page-size alignment, and installs the bundle with bundletool before anything reaches Play. Releases move from internal testing to a staged production rollout tied to crash and ANR thresholds, and every upload, promotion and policy submission waits for your confirmation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- android-play-release Installs to ~/.agents/skills/android-play-release/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install android-play-release The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project android-play-release Installs to .agents/skills/android-play-release/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/android-play-release.zipunzip -o android-play-release.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) android-play-release PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $android-play-release, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Ships Android apps to Google Play - release signing with an upload key and Play App Signing, Android App Bundles built with Gradle, versionCode rules, R8 mapping and native symbols, 16 KB page-size checks for native libraries, bundletool testing, internal, closed, open and production tracks with staged rollouts, the new-account testing requirement, and Play Console policy declarations such as Data safety and sensitive permissions. Use when preparing a Play release, setting up signing, a bundle is rejected on upload, a policy warning arrives, or planning a staged rollout.
Use it when
- Preparing a release bundle for Google Play
- Setting up or repairing upload-key signing
- An upload is rejected for target API, page size or signing
- Responding to a Play policy or permissions warning
Use something else when
- You want fastlane supply lanes and CI signing: use mobile-ci-fastlane
- The app is built and submitted with Expo EAS: use expo-eas-builds
How to check the result
check_16kb_alignment.py exits 0 on the AAB, keytool shows the upload certificate rather than the debug one, and the bundletool-installed release passes a smoke test on a real device.
The mistake it prevents
A third-party SDK ships a prebuilt arm64 library with 4 KB segment alignment, and the update is blocked even though the app's own native code was rebuilt correctly.
Done well means: the bundle is signed with the upload key from a secret store, it passes Play’s checks on first upload (target API, 16 KB alignment, permissions declared), it reaches testers on a test track before production, and production goes out as a staged rollout someone is watching.
Release gates
Uploading to Play, promoting a release, changing rollout percentage, halting a rollout and submitting policy declarations all act on the user’s live developer account. Prepare the build and the notes, then get explicit confirmation before each of those steps.
Inspect first
cat AGENTS.md 2>/dev/null
grep -nE 'applicationId|versionCode|versionName|targetSdk|minSdk|signingConfig|isMinifyEnabled|debugSymbolLevel' \
app/build.gradle.kts
grep -n '<uses-permission\|foregroundServiceType' app/src/main/AndroidManifest.xml
ls fastlane/ .github/workflows/ 2>/dev/null
Requirements that block uploads
| Requirement | Current rule | Check |
|---|---|---|
| Format | New apps must ship Android App Bundles (.aab) | ./gradlew bundleRelease |
| Target API | Raised every year at the end of August (API 35 from 31 Aug 2025); check Play’s current floor | targetSdk in Gradle |
| 16 KB page size | Since 1 Nov 2025, new apps and updates targeting Android 15+ need 16 KB-compatible native libraries | python3 scripts/check_16kb_alignment.py app.aab |
| versionCode | Strictly greater than every code ever uploaded for the package, max 2100000000 | Gradle / CI number |
| Signing | Upload key registered with Play App Signing | keytool -printcert -jarfile app.aab |
Signing
Play App Signing holds the app signing key; you sign uploads with an upload key.
keytool -genkeypair -v -keystore upload.jks -alias upload \
-keyalg RSA -keysize 2048 -validity 10000
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
storeFile = System.getenv("UPLOAD_KEYSTORE")?.let { file(it) }
storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
ndk { debugSymbolLevel = "FULL" } // only if the app ships native code
}
}
}
- Keystores and passwords live in the CI secret store or a local
keystore.propertiesthat is gitignored. Never commit them, never echo them in logs, never paste them in chat. - Back up the upload keystore. If it is lost or leaked, the account owner requests an upload key reset in Play Console; that is slow, so prevent it.
- Generating keys and editing signing config is local work; registering or resetting keys in Play Console is the user’s action.
Build and test the bundle
./gradlew clean bundleRelease
python3 scripts/check_16kb_alignment.py app/build/outputs/bundle/release/app-release.aab
keytool -printcert -jarfile app/build/outputs/bundle/release/app-release.aab # upload cert, not debug
# Install what Play would deliver to a connected device (bundletool from Maven/GitHub releases)
bundletool build-apks --bundle=app/build/outputs/bundle/release/app-release.aab \
--output=build/app.apks --connected-device \
--ks="$UPLOAD_KEYSTORE" --ks-key-alias=upload --ks-pass=env:UPLOAD_KEYSTORE_PASSWORD
bundletool install-apks --apks=build/app.apks
The script reads every native library in an AAB, APK, directory or single .so, fails
64-bit libraries whose PT_LOAD alignment is under 16 KB, and for APKs also fails
uncompressed 64-bit libraries that are not 16 KB aligned in the zip. Fixes: NDK r28+
(16 KB by default), -Wl,-z,max-page-size=16384 for older NDKs, AGP 8.5.1+ for zip
alignment, and updated versions of SDKs that ship prebuilt .so files (the usual
culprits). Test on the Android 15+ 16 KB emulator image.
Smoke-test the release build: R8 removes classes used only via reflection (JSON models, DI), and crashes there show up only in release.
Tracks
| Track | Audience | Review | Use for |
|---|---|---|---|
| Internal testing | Up to 100 testers by email list | Fast, usually minutes | Every release candidate |
| Closed testing | Lists or Google Groups | Standard review | Beta programmes, required pre-launch testing |
| Open testing | Anyone with the listing link | Standard review | Public beta |
| Production | Everyone in selected countries | Standard review | Staged rollout |
New personal developer accounts (created after 13 Nov 2023) must run a closed test with at least 12 opted-in testers for 14 consecutive days before they can apply for production access. Plan this into the first launch date.
Staged rollout
- [ ] Release candidate on internal track, smoke-tested on low-end and latest Android
- [ ] Release notes per language (500 chars each)
- [ ] Policy declarations current (below)
- [ ] Promote to production at 5-10% (user confirmed)
- [ ] Watch Android vitals: user-perceived crash rate and ANR rate vs previous release
(Play's bad-behaviour thresholds: 1.09% crashes, 0.47% ANRs overall)
- [ ] Increase 10% -> 25% -> 50% -> 100% over days, each step confirmed
- [ ] Halt the rollout if vitals regress; fix with a higher versionCode
Halting stops new installs of the release; users who already updated keep it. There is no rollback to a lower versionCode: ship a fixed build.
Automating uploads
Use the Play Developer API through one tool: Gradle Play Publisher
(com.github.triplet.play, ./gradlew publishReleaseBundle), fastlane supply
($mobile-ci-fastlane), or a CI action. The service account needs only the release
permissions for this app. Default automation to the internal track; promotion to
production stays a human decision.
Policy declarations in Play Console
| Trigger in the app | Declaration |
|---|---|
| Any data collected or shared (including by SDKs: analytics, ads, crash) | Data safety form matching actual SDK behaviour |
| Accounts can be created | In-app account deletion plus a web link for deletion requests |
ACCESS_BACKGROUND_LOCATION, SMS/Call Log, QUERY_ALL_PACKAGES, MANAGE_EXTERNAL_STORAGE | Permission declaration form with core-use justification and video |
| Foreground services (targetSdk 34+) | foregroundServiceType in manifest plus FGS declaration |
READ_MEDIA_IMAGES / READ_MEDIA_VIDEO | Allowed only for core broad-access use; otherwise use the Android photo picker |
| Ads SDK present | “Contains ads” declaration |
| Children in target audience | Families policy requirements and certified ad SDKs |
Removing a sensitive permission you do not need is faster than any declaration. Draft declaration answers for the user to review; they submit them.
Gotchas
versionCodecollisions across flavors or ABI splits: derive codes deterministically (e.g. base code times 10 plus variant digit) and never reuse one, even for a failed upload.- Debug-signed bundles are rejected; check the certificate before uploading.
- Merged manifest permissions come from libraries too. Inspect
app/build/intermediates/merged_manifests/(or the Merged Manifest tab) and remove unwanted ones withtools:node="remove". - Upload R8 mapping for the exact build: mapping is embedded in the AAB; builds rebuilt later produce different mappings.
- Changing
applicationIdcreates a different app on Play; it is not a rename.
Report template
**Build:** com.acme.app 2.4.0 (versionCode 20400), targetSdk 35, minSdk 24
**Checks:** check_16kb_alignment.py OK (6 arm64/x86_64 libs); upload cert SHA-256 matches Play; release smoke test on Pixel 8 + Galaxy A15
**Track:** internal (uploaded after confirmation); production rollout plan 10% -> 25% -> 50% -> 100%
**Policy:** Data safety updated for new crash SDK; READ_MEDIA_IMAGES removed in favour of photo picker
**Open items:** awaiting go-ahead to promote to production What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Android Play Release"
short_description: "Bundles that pass Play checks on first upload"
default_prompt: "$android-play-release Set up release signing and build an AAB for the Play internal track" scripts/check_16kb_alignment.py
#!/usr/bin/env python3
"""Check native libraries for Android 16 KB page-size compatibility.
Usage:
python3 check_16kb_alignment.py <app.aab | app.apk | lib.so | directory> [...]
For every 64-bit ELF shared library (arm64-v8a, x86_64) it reads the program headers
and requires each PT_LOAD segment alignment to be at least 16 KB (2**14). For APKs it
also checks that uncompressed .so entries start on a 16 KB boundary inside the zip
(zipalign -P 16). 32-bit libraries are reported but not failed. Exits 1 on any failure.
Standard library only.
"""
from __future__ import annotations
import struct
import sys
import zipfile
from pathlib import Path
PAGE = 16 * 1024
PT_LOAD = 1
def load_alignments(data: bytes) -> tuple[int, list[int]] | None:
"""Return (bits, [p_align of each PT_LOAD]) or None if not an ELF file."""
if len(data) < 64 or data[:4] != b"\x7fELF":
return None
bits = {1: 32, 2: 64}.get(data[4])
endian = {1: "<", 2: ">"}.get(data[5])
if bits is None or endian is None:
return None
if bits == 64:
phoff, = struct.unpack_from(endian + "Q", data, 0x20)
phentsize, phnum = struct.unpack_from(endian + "HH", data, 0x36)
else:
phoff, = struct.unpack_from(endian + "I", data, 0x1C)
phentsize, phnum = struct.unpack_from(endian + "HH", data, 0x2A)
aligns = []
for i in range(phnum):
off = phoff + i * phentsize
if off + phentsize > len(data):
break
p_type, = struct.unpack_from(endian + "I", data, off)
if p_type != PT_LOAD:
continue
if bits == 64:
p_align, = struct.unpack_from(endian + "Q", data, off + 0x30)
else:
p_align, = struct.unpack_from(endian + "I", data, off + 0x1C)
aligns.append(p_align)
return bits, aligns
def zip_data_offset(fh, info: zipfile.ZipInfo) -> int:
fh.seek(info.header_offset)
header = fh.read(30)
name_len, extra_len = struct.unpack("<HH", header[26:30])
return info.header_offset + 30 + name_len + extra_len
def check_elf(label: str, data: bytes, results: list) -> None:
parsed = load_alignments(data)
if parsed is None:
results.append(("SKIP", label, "not an ELF file"))
return
bits, aligns = parsed
worst = min(aligns) if aligns else 0
if bits == 32:
results.append(("INFO", label, f"32-bit library, min LOAD align {worst} (not required)"))
elif worst >= PAGE:
results.append(("OK", label, f"min LOAD align {worst}"))
else:
results.append(("FAIL", label, f"min LOAD align {worst} < {PAGE}: rebuild with NDK r28+ "
"or -Wl,-z,max-page-size=16384, or update the SDK that ships it"))
def check_archive(path: Path, results: list) -> None:
is_apk = path.suffix == ".apk"
with zipfile.ZipFile(path) as z, open(path, "rb") as fh:
libs = [i for i in z.infolist() if i.filename.endswith(".so") and "/lib/" in "/" + i.filename]
if not libs:
results.append(("INFO", str(path), "no native libraries found"))
for info in libs:
label = f"{path.name}:{info.filename}"
data = z.read(info)
check_elf(label, data, results)
parsed = load_alignments(data)
if is_apk and parsed and parsed[0] == 64 and info.compress_type == zipfile.ZIP_STORED:
offset = zip_data_offset(fh, info)
if offset % PAGE:
results.append(("FAIL", label, f"stored at zip offset {offset}, not 16 KB aligned "
"(use zipalign -P 16 / AGP 8.5.1+)"))
def main(argv: list[str]) -> int:
if not argv:
print(__doc__)
return 2
results: list = []
for arg in argv:
p = Path(arg)
if p.is_dir():
for so in sorted(p.rglob("*.so")):
check_elf(str(so), so.read_bytes(), results)
elif p.suffix in (".apk", ".aab", ".zip"):
check_archive(p, results)
elif p.is_file():
check_elf(str(p), p.read_bytes(), results)
else:
results.append(("FAIL", arg, "not found"))
for status, label, msg in results:
print(f"{status:5} {label} {msg}")
failures = sum(1 for r in results if r[0] == "FAIL")
print(f"RESULT: {'FAIL' if failures else 'OK'} ({failures} failing of {len(results)} checked)")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.