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.

  • Mobile & Desktop
  • $android-play-release
  • 175 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- android-play-release

Installs to ~/.agents/skills/android-play-release/ for every project on this machine.

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

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.

~/.agents/skills/android-play-release/SKILL.md

--- name: android-play-release description: Ships Android apps to Google Play - release signing with an upload key and Play App Signing, Android App Bundles built with Gradle, versionCode… license: MIT ---

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

RequirementCurrent ruleCheck
FormatNew apps must ship Android App Bundles (.aab)./gradlew bundleRelease
Target APIRaised every year at the end of August (API 35 from 31 Aug 2025); check Play’s current floortargetSdk in Gradle
16 KB page sizeSince 1 Nov 2025, new apps and updates targeting Android 15+ need 16 KB-compatible native librariespython3 scripts/check_16kb_alignment.py app.aab
versionCodeStrictly greater than every code ever uploaded for the package, max 2100000000Gradle / CI number
SigningUpload key registered with Play App Signingkeytool -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.properties that 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

TrackAudienceReviewUse for
Internal testingUp to 100 testers by email listFast, usually minutesEvery release candidate
Closed testingLists or Google GroupsStandard reviewBeta programmes, required pre-launch testing
Open testingAnyone with the listing linkStandard reviewPublic beta
ProductionEveryone in selected countriesStandard reviewStaged 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 appDeclaration
Any data collected or shared (including by SDKs: analytics, ads, crash)Data safety form matching actual SDK behaviour
Accounts can be createdIn-app account deletion plus a web link for deletion requests
ACCESS_BACKGROUND_LOCATION, SMS/Call Log, QUERY_ALL_PACKAGES, MANAGE_EXTERNAL_STORAGEPermission declaration form with core-use justification and video
Foreground services (targetSdk 34+)foregroundServiceType in manifest plus FGS declaration
READ_MEDIA_IMAGES / READ_MEDIA_VIDEOAllowed only for core broad-access use; otherwise use the Android photo picker
Ads SDK present“Contains ads” declaration
Children in target audienceFamilies 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

  • versionCode collisions 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 with tools:node="remove".
  • Upload R8 mapping for the exact build: mapping is embedded in the AAB; builds rebuilt later produce different mappings.
  • Changing applicationId creates 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.yamlgenerated from this page

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.py119 lines, 4.4 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem