Skip to content

Publish Android builds to the Google Play internal test track #106

Description

@diegurrio

Goal: every v* tag builds a signed Android App Bundle (AAB) and uploads it to
the Google Play internal test track under the Dronecode Foundation account.
The GitHub release APK becomes signed with the same upload key. The
application id stays com.px4.hawkeye.android and becomes permanent at the
first Play upload. The first upload must be done by hand because the Play
Developer API cannot create a new app.

Background for anyone picking up an item: releases are cut by pushing a v*
tag, which runs .github/workflows/release.yml. Its android-apk job builds
with ./gradlew assembleRelease -PhawkeyeVersionName=<version> (gradlew is
in android/, not the repo root) and attaches
hawkeye--android-unsigned.apk to the GitHub release. versionCode is
derived in android/app/build.gradle.kts as major1000000 + minor1000 +
patch, which makes v0.4.0-rc1 collide with v0.4.0 (documented in
docs/developer/releasing.md). There is no signing config, keystore, or AAB
anywhere in the repo today. New Play apps must upload an AAB and are enrolled
in Play App Signing: Google holds the app signing key, we hold an upload key.
Highest shipped versionCode so far is 3000 (v0.3.0).

Console and secrets (manual, needs Dronecode Play account access)

  • Mint the upload keystore and back it up.
    Run:
    keytool -genkeypair -v -keystore hawkeye-upload.jks -storetype PKCS12 -alias hawkeye-upload -keyalg RSA -keysize 4096 -validity 10950 -dname "CN=Hawkeye, O=Dronecode Foundation"
    PKCS12 keystores use a single password for store and key. Record the
    certificate fingerprint with
    keytool -list -v -keystore hawkeye-upload.jks -alias hawkeye-upload | grep SHA256.
    Store the keystore file, password, alias, fingerprint, and creation date as
    one entry in the Dronecode password manager. Note in the entry that Play App
    Signing allows an upload key reset through the Play Console, so this backup
    is a convenience rather than the last line of defense.
    Done when: the entry exists in the password manager and the .jks is deleted
    from any shared or synced folder.
    Done 2026-08-16: keystore minted (RSA 4096, PKCS12, alias hawkeye-upload,
    valid to 2056) with upload certificate SHA-256
    DB:DF:32:2A:02:A8:2B:3D:8A:BA:E9:EE:26:1E:6C:EF:D9:C8:16:22:DD:0D:A3:03:B7:4F:A2:B1:A5:D4:89:FE.

  • Add the three signing secrets to the PX4/Hawkeye repo.
    From a machine with the keystore:
    gh secret set ANDROID_UPLOAD_KEYSTORE_BASE64 --repo PX4/Hawkeye --body "$(base64 -i hawkeye-upload.jks)"
    gh secret set ANDROID_UPLOAD_KEYSTORE_PASSWORD --repo PX4/Hawkeye (paste the password)
    gh secret set ANDROID_UPLOAD_KEY_ALIAS --repo PX4/Hawkeye --body hawkeye-upload
    Done when: all three appear in the repo secrets list.
    Done 2026-08-16: all three secrets set, and a workflow_dispatch run of
    android.yml exercised the full signed path with them (keystore decode,
    signed build, apksigner and jarsigner verification all passing):
    https://github.com/PX4/Hawkeye/actions/runs/31952546318

  • Create the Hawkeye app in the Play Console.
    In the Dronecode Foundation Play Console account: All apps, Create app.
    Name Hawkeye, type App, Free, default language English. This step cannot be
    done through the API.
    Done when: the app exists in the console (its package name is fixed later
    by the first AAB upload, which must be built with applicationId
    com.px4.hawkeye.android).

  • Set up the internal testing track and tester list.
    Play Console: Testing, Internal testing, create an email list (up to 100
    testers) and attach it to the track. Internal testing does not require a
    store listing, content rating, or data safety form to roll out; if the
    console blocks on a specific declaration anyway, complete only what it
    demands.
    Done when: the internal track shows the tester list attached.
    Done 2026-08-16: track is live and testers join through the opt-in link
    https://play.google.com/apps/internaltest/4700645534848215513
    Note for onboarding testers: a tester must be on the email list AND visit
    the opt-in link once before the app shows up for them in Play.

  • Build and upload the first signed AAB by hand.
    Prerequisite: the Gradle signing item under Repository changes is merged.
    With the keystore locally:
    export HAWKEYE_UPLOAD_KEYSTORE=/absolute/path/hawkeye-upload.jks
    export HAWKEYE_UPLOAD_KEYSTORE_PASSWORD=<password>
    export HAWKEYE_UPLOAD_KEY_ALIAS=hawkeye-upload
    cd android && ./gradlew bundleRelease -PhawkeyeVersionName=0.4.0-rc1
    Upload android/app/build/outputs/bundle/release/app-release.aab to the
    internal testing track, accept the Google generated app signing key when
    prompted (this enrolls Play App Signing), and roll out. This consumes
    versionCode 400001, so the git tag v0.4.0-rc1 must never be pushed;
    automation starts at v0.4.0-rc2.
    Done when: the internal track shows release 0.4.0-rc1 rolled out and
    testers can install it.
    Done 2026-08-16: 0.4.0-rc1 (versionCode 400001) uploaded and rolled out to
    internal testing; Play App Signing enrolled with a Google generated app
    signing key. The git tag v0.4.0-rc1 is therefore permanently reserved and
    must never be pushed; automation starts at v0.4.0-rc2.

  • Create the service account for CI uploads.
    In Google Cloud Console, in a Dronecode owned project (create one named
    hawkeye-play-publisher if none fits): enable the Google Play Android
    Developer API, create a service account (no GCP roles needed), and create a
    JSON key for it.
    Done when: the JSON key file is downloaded and the API shows enabled.

  • Grant the service account access in the Play Console.
    Play Console: Users and permissions, Invite new user, enter the service
    account email. Grant app level access to Hawkeye only, with permissions
    View app information (read only) and Release apps to testing tracks.
    Nothing for production or app signing.
    Known delay: a freshly invited service account can return API permission
    errors for up to 24 hours; wait or edit any console setting before
    debugging a failed upload.
    Done when: the service account is listed under Users and permissions with
    those two permissions.

  • Add the service account secret and clean up.
    gh secret set PLAY_SERVICE_ACCOUNT_JSON --repo PX4/Hawkeye < service-account.json
    Then delete every local copy of the JSON key.
    Done when: the secret exists and no local copy of the key remains.
    Done 2026-08-16: hawkeye-ci-publisher@api-7299061481369082666-700664
    .iam.gserviceaccount.com created in the Play linked GCP project with no GCP
    roles, JSON key stored as PLAY_SERVICE_ACCOUNT_JSON and deleted locally,
    and the account granted View app information plus Release apps to testing
    tracks on Hawkeye only.

Repository changes

  • Ignore keystores. Add *.jks and *.keystore to the root
    .gitignore under the Android section.
    Done when: git check-ignore hawkeye-upload.jks matches.

  • Conditional upload signing in Gradle.
    File: android/app/build.gradle.kts. Read env vars via
    providers.environmentVariable: HAWKEYE_UPLOAD_KEYSTORE (path to a decoded
    .jks), HAWKEYE_UPLOAD_KEYSTORE_PASSWORD, HAWKEYE_UPLOAD_KEY_ALIAS. When the
    keystore var is present, create signingConfigs "upload" (keyPassword equals
    storePassword, PKCS12) and set it on the release build type; when absent,
    change nothing so local, PR, and fork builds keep producing an unsigned
    release APK. Note: when signed, the output becomes app-release.apk instead
    of app-release-unsigned.apk; the verify script resolves the file through
    output-metadata.json so no path fixes are needed.
    Done when: a local ./gradlew assembleRelease -PhawkeyeVersionName=0.0.0-dev
    without env vars still builds unsigned, and with the three env vars set the
    APK passes apksigner verify.

  • versionCode scheme with a prerelease component.
    File: android/app/build.gradle.kts (the hawkeyeVersionCode block).
    New formula: major100000000 + minor100000 + patch*100 + rc, where rc is
    99 for a final version (no suffix), N for suffix rcN with N in 1..98, and 0
    for suffixes dev and ci. Any other suffix fails the build. Require major in
    0..20 so the maximum stays below the Play cap of 2100000000. Examples:
    0.4.0-rc1 gives 400001, 0.4.0 gives 400099, 1.2.3 gives 100200399. Keep the
    existing coerceAtLeast(1) so 0.0.0-dev still yields 1. All new codes exceed
    3000 (shipped v0.3.0), so sideload upgrades stay monotonic. Update the
    comment block above it.
    Done when: the examples above hold (check with aapt dump badging on a built
    APK) and an unknown suffix fails the build with a clear message.

  • Signed mode for the APK verify script.
    File: android/scripts/verify-release-apk.sh. Add an optional --signed flag
    preserving current behavior without it. With --signed, locate apksigner in
    the newest $ANDROID_SDK_ROOT/build-tools/* (fall back to $ANDROID_HOME) and
    fail the script if apksigner verify --print-certs fails.
    Done when: the script passes on a signed APK with --signed, fails on an
    unsigned APK with --signed, and is unchanged without the flag.

  • New AAB verify script.
    New file: android/scripts/verify-release-bundle.sh, matching the style of
    verify-release-apk.sh. Given the path to an .aab, assert it exists and that
    unzip -l lists base/lib/arm64-v8a/libhawkeye.so,
    base/lib/x86_64/libhawkeye.so, and the four asset trees base/assets/models,
    base/assets/shaders, base/assets/fonts, base/assets/themes. No bundletool
    dependency.
    Done when: the script passes on a real bundleRelease output and fails when
    pointed at an APK or a bundle missing an asset tree.

  • Exercise signing and bundling in android.yml before any tag.
    File: .github/workflows/android.yml, job release-build. Route the keystore
    secret through job env (UPLOAD_KEYSTORE_B64 from
    secrets.ANDROID_UPLOAD_KEYSTORE_BASE64) because the secrets context is
    unavailable in job level if. Add a step guarded by
    if: env.UPLOAD_KEYSTORE_B64 != '' that decodes it to
    $RUNNER_TEMP/upload-keystore.jks and exports HAWKEYE_UPLOAD_KEYSTORE via
    GITHUB_ENV. Build with ./gradlew assembleRelease bundleRelease -PhawkeyeVersionName=0.0.0-ci (passwords and alias as step env from
    secrets). Verify the APK with --signed when the keystore is present and
    without it otherwise, and verify the AAB with the new script. Never upload
    to Play from this workflow. Forks without secrets must stay green on the
    unsigned path.
    Done when: a run on main with secrets shows the signed verify and AAB
    verify steps passing, and a PR from a fork still passes.
    Done 2026-08-16: the post-merge run on main decoded the keystore and passed
    the signed APK and AAB verifications:
    https://github.com/PX4/Hawkeye/actions/runs/31954206015

  • Sign, bundle, and upload in release.yml.
    File: .github/workflows/release.yml, job android-apk (rename to android,
    update its name accordingly). Same keystore decode pattern as android.yml.
    Build assembleRelease bundleRelease with -PhawkeyeVersionName from
    needs.source-tarball.outputs.version. Verify the APK (--signed when the
    keystore is present) and the AAB. Stage the APK as
    hawkeye--android.apk when signed, or keep
    hawkeye--android-unsigned.apk when not (forks), and upload it to
    the GitHub release with gh release upload as today. Remove the stale
    comment about dropping the -unsigned suffix. Add a final step, guarded on
    env.PLAY_SA_JSON != '' (routed from secrets.PLAY_SERVICE_ACCOUNT_JSON via
    job env), using the r0adkll/upload-google-play action pinned to a commit
    SHA with a version comment: serviceAccountJsonPlainText from the secret,
    packageName com.px4.hawkeye.android, releaseFiles pointing at
    android/app/build/outputs/bundle/release/app-release.aab, track internal,
    status completed. Do not gate on prerelease tags: rc uploads are the point
    of the internal track. Do not attach the AAB to the GitHub release.
    Done when: workflow lint passes and every new step is guarded so a fork or
    a repo without Play secrets falls back to today's behavior.

  • Docs.
    docs/developer/releasing.md: asset name hawkeye--android.apk, new
    versionCode formula with an example table (v0.4.0-rc1 gives 400001, v0.4.0
    gives 400099, v1.2.3 gives 100200399, no tag gives 1), delete the stale
    prerelease collision warning, rewrite the Android APK section (signed with
    the upload key, secret names, fork fallback), add a Google Play internal
    track section covering what uploads when and the service account setup.
    docs/installation.md: new APK name, remove the unsigned APK block, state
    the APK installs directly, add one line pointing testers at the Play
    internal testing list.
    docs/troubleshooting.md: rewrite the install failure entry to cover
    downgrade attempts and the one time certificate mismatch for anyone who
    sideloaded a self signed v0.1.0 to v0.3.0 APK (they must uninstall once).
    README.md: new APK name, drop the sign it yourself caveat.
    android/README.md: replace the manual signing section with the CI signing
    story (env vars, conditional config, local builds stay unsigned, keep a
    short self signing recipe), update the CI job table.
    Done when: no doc references hawkeye--android-unsigned.apk except
    as the documented fork fallback, and the next release notes mention the
    uninstall once migration.
    Done 2026-08-16: merged to main in Signed release APK and Google Play internal track upload #108 (with Android release signing, AAB build, and prerelease version codes #107). The release notes
    reminder lands with the next release; releasing.md carries the instruction.

  • End to end validation.
    Prerequisites: everything above. Confirm a green release-build run on main
    showing the signed path. Push the next rc tag (v0.4.0-rc2 or later, never
    v0.4.0-rc1 which was consumed by the manual upload). Confirm the GitHub
    release contains hawkeye--android.apk and it installs directly on
    a device, the Play internal track shows the new release with a higher
    versionCode than the manual upload, and testers receive the update.
    Done when: all of the above observed, then close this issue.
    Done 2026-08-16: tag v0.4.0-rc2 ran the full pipeline green on the first
    try. The GitHub release carries hawkeye-0.4.0-rc2-android.apk (signed), the
    Play upload step succeeded and the internal track received versionCode
    400002, and the released APK upgrade-installed over the sideloaded 400001
    on a physical device, confirming signature continuity.
    https://github.com/PX4/Hawkeye/actions/runs/31965242813

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions