Play now sends this app's changes for review automatically and the Edits API rejects the parameter, so the AAB uploaded but the edit never committed.
15 KiB
name, description
| name | description |
|---|---|
| releasing-healthchat | Cut a HealthChat Google Play release of element-x-android by tagging release/healthchat. Use when the user asks to release, publish, tag, deploy, ship, or cut a new HealthChat Android version, asks about the Play Store release process, asks about rotating the Android release keystore, or asks about the required GitHub Actions secrets for the release pipeline. |
Releasing HealthChat (element-x-android)
Releases are driven by git tags of the form healthchat-v<version> pushed to
origin. The tag triggers .github/workflows/healthchat-release-playstore.yml,
which runs on the self-hosted novaloop-incus-large runner, builds a signed
AAB, and uploads it to the Play Store internal track. No GitHub Release is
created.
Promotion to production is always a manual step in Play Console after an internal build has been validated — see "Promoting to production" below.
Preflight checklist
Verify each item before tagging. If any fail, fix and commit first.
- On branch
release/healthchatwith a clean working tree (git status). HEALTHCHAT_CHANGES.mdhas a section## Changes in <new-version> (<date>)at the top of the file. The pipeline's awk extractor matches this exact heading format; no match means a fallback placeholder ships as the Play Store "What's new" text.plugins/src/main/kotlin/Versions.ktversionYear,versionMonth, andversionReleaseNumberproduce the tag's core version (format<year>.<month zero-padded to 2>.<releaseNumber>, e.g.26.07.1). The workflow hard-fails if they don't match — this is load-bearing, not cosmetic.- If you edited
HEALTHCHAT_CHANGES.mdorVersions.kt, both commits are pushed torelease/healthchatbefore you push the tag.
Cutting the release (normal path)
# from release/healthchat, fully committed:
git tag healthchat-v<YEAR>.<MONTH>.<RELEASE>
git push origin healthchat-v<YEAR>.<MONTH>.<RELEASE>
Examples:
git tag healthchat-v26.07.1(next release in July 2026)git tag healthchat-v26.08.0(first release in August 2026)git tag healthchat-v26.07.2-rc1(release candidate)
Watch the workflow with gh run watch or in the GitHub Actions UI. When it
finishes, the build appears in Play Console → Internal testing within a few
minutes (Play Store's processing queue).
Manual / retry path (workflow_dispatch)
Use when retrying a failed run or rebuilding without moving tags.
- GitHub → Actions → "HealthChat Release to Google Play Store" → Run workflow.
- Fill in:
- version (required): e.g.
26.07.1. Same regex validation as tag path. Must still matchVersions.kt. - track: default
internal; pickalpha/beta/productionto promote an already-uploaded build to a higher track without rebuilding (note: Play Console is usually a better UX for promotion). - release_status: default
completed; usedraftfor dry-runs that land in Play Console but stay unpublished. - release_notes_override (optional): replaces the
HEALTHCHAT_CHANGES.mdlookup. Useful for ad-hoc test builds.
- version (required): e.g.
- Run. The AAB is built and uploaded exactly as the tag path would.
Runner and build tuning
The job runs on novaloop-incus-large — an incus container with 4 cores and
24 GiB, not a GitHub-hosted runner. Two consequences the workflow handles, and
that anyone editing it needs to keep in mind:
- The image ships no Android SDK. The
Set up Android SDKstep installs it. Platform packages are minor-versioned, soCOMPILE_SDK = 37meansplatforms;android-37.0. Build-tools follows AGP's own default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), notBUILD_TOOLS_VERSIONinVersions.kt— nothing setsbuildToolsVersion, so bump it when AGP is upgraded. GRADLE_OPTSis deliberately smaller than upstream's. Upstream's workflows use-Xmx8gwith a 4 GB Kotlin daemon, sized for the 4-core/16GBubuntu-latestthat public repos get; this repo is private, so its hosted runners are 2-core/7GB and its container is 24 GiB. At upstream's numbers the daemon plus the Kotlin daemon plus four parallel lint/R8 workers exceed the limit and the daemon is SIGKILLed — the symptom isDaemonDisappearedExceptionwith noOutOfMemoryErrorand no heap dump, which is what an external kill looks like.-Xmx6gwith a 2 GB Kotlin daemon fits.
The Report runner capacity step prints free, nproc, df and the enforced
cgroup limit on every run, so a future failure can be read against the runner's
actual capacity rather than guessed at. Note free reports the container's
limit only when lxcfs virtualises /proc/meminfo; /sys/fs/cgroup/memory.max
is the number the kernel enforces.
Versioning (calendar scheme — differs from the old Classic repo)
Element X uses calendar versioning, defined in
plugins/src/main/kotlin/Versions.kt:
versionName = <versionYear>.<versionMonth padded to 2>.<versionReleaseNumber> // e.g. 26.07.1
versionCode = ((2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber) * 10
The trailing * 10 is the ABI multiplier applied per output APK
(app/build.gradle.kts androidComponents block); the AAB gets ABI code 0.
Example: 26.07.1 → versionCode 202607010.
To bump: edit versionYear/versionMonth/versionReleaseNumber in
Versions.kt (increment versionReleaseNumber within a month; reset it to 0
when the month changes). versionCode must be strictly monotonic in Play
Store across all uploads — the calendar scheme guarantees this as long as you
never decrease the triple. All HealthChat codes (≥ 202,607,000) supersede the
old Classic app's last code (40,154,100).
Keep the same version as the upstream Element X release the branch is based
on unless HealthChat ships an intermediate build — then bump
versionReleaseNumber past upstream's. When merging a newer upstream release
into release/healthchat, take upstream's Versions.kt if its version is
higher.
Release notes
HEALTHCHAT_CHANGES.md is the single source. The workflow extracts the
section matching the version being released, then writes it to
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt and
fastlane/metadata/android/de-DE/changelogs/<versionCode>.txt, which
r0adkll/upload-google-play picks up via its whatsNewDirectory input.
Format:
## Changes in 26.07.1 (2026-07-20)
🙌 Improvements
- Bullet one.
- Bullet two.
Keep the entire section under 500 characters — Play Store's "What's new" hard cap per locale. The workflow emits a warning if you exceed it; Play Store will truncate.
Never hand-edit the generated fastlane/metadata/android/*/changelogs/<N>.txt
files for this branch — they'll be overwritten on the next run. Only edit
HEALTHCHAT_CHANGES.md. (Older checked-in files from upstream are harmless.)
If no matching section exists, the pipeline emits a workflow warning and
falls back to HealthChat <version> — automated build from <ref>.. Prefer
fixing the changelog over relying on the fallback.
After the release
- Workflow run is green in GitHub Actions.
- Build appears under Play Console → Internal testing within ~10 minutes.
- Internal testers get the update automatically if they're enrolled.
- AAB +
mapping.txtare attached to the workflow run, retained 90 days.
A green "Build Release AAB" step does not mean the release shipped. The
upload step logs Successfully uploaded 1 artifacts before it commits the Play
edit; if the commit fails, the AAB is uploaded but no release exists on the
track. Always check the Upload to Play Store step, not just the job's build
steps.
Troubleshooting: changesNotSentForReview must not be set
Successfully uploaded 1 artifacts
Committing the Edit
##[error]Changes are sent for review automatically.
The query parameter changesNotSentForReview must not be set.
Google flips this per app: once Play sends an app's changes for review
automatically, the Edits API rejects changesNotSentForReview instead of
ignoring it. The 26.07.0 release (2026-07-27) committed fine with the flag
set; 26.08.0 (2026-08-03) failed with it. The parameter is no longer passed —
if it ever needs to come back, gate it rather than setting it unconditionally.
Recovery: the failed edit is never committed, so the versionCode is not
consumed. Re-run via workflow_dispatch with the same version — no tag move
and no version bump needed.
Required GitHub Actions secrets
The pipeline depends on these repository secrets. Missing any causes the
workflow to fail at the corresponding step. The values are identical to the
ones configured on the old Novaloop-AG/element-android repository (same
upload keystore, same Play listing) — except GOOGLE_SERVICES_JSON_BASE64,
which element-x-android does not use (Firebase config is compiled in via
libraries/pushproviders/firebase/src/main/res/values/firebase.xml).
| Secret | Source |
|---|---|
RELEASE_KEYSTORE_BASE64 |
base64 -w0 release.keystore (the HealthChat upload keystore, alias healthchat-upload) |
RELEASE_KEYSTORE_PASSWORD |
Keystore password |
RELEASE_KEY_ALIAS |
Key alias inside the keystore |
RELEASE_KEY_PASSWORD |
Key password |
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON |
Play Console → Users and permissions → invite a service account → download JSON. Needs "Release manager" permissions scoped to ch.healthchat.element. |
Keystore rotation (rare, high-risk)
Unlike Apple Distribution certificates, Android upload keystores do not expire and should not be rotated casually. The upload key is tied to the app's identity in Play Console (Play App Signing is enabled; the repo key is the upload key); rotating it is a one-way process that uses Play App Signing's "Upload key reset" flow.
If the keystore is lost or believed to be compromised:
- Play Console → Release → Setup → App integrity → Upload key → Request upload key reset. Instructions follow, requiring you to upload a new keystore and wait for Google to rotate.
- Generate a fresh keystore on the operator's machine:
keytool -genkey -v -keystore healthchat-upload-new.jks \ -keyalg RSA -keysize 2048 -validity 10000 -alias healthchat - Once Google confirms the rotation, re-encode and update secrets:
base64 -w0 healthchat-upload-new.jks | pbcopy, then replaceRELEASE_KEYSTORE_BASE64,RELEASE_KEYSTORE_PASSWORD,RELEASE_KEY_ALIAS,RELEASE_KEY_PASSWORDin repo settings. - Run the release workflow via
workflow_dispatchwith a throwaway version bump andrelease_status: draftto verify end-to-end. Discard the draft.
Do not attempt rotation without reading Play App Signing docs end-to-end and coordinating a maintenance window. A botched rotation means users can't receive updates until Google completes the reset.
Promoting internal → production
Internal builds and production builds are the same AAB. To release a validated internal build:
- Play Console → HealthChat → Testing → Internal testing → find the release.
- Confirm it passed internal testing.
- Promote release → Production → Release details → fill in "What's new" (Play Console copies it from the internal release — verify) → Review release → Start rollout to Production.
- Choose staged rollout % if desired.
This step is deliberately not automated. Production releases require human judgment on rollout percentage, Play Store listing changes, and compliance metadata review.
Rollback
- Bad build, release notes fine → fix the cause, bump
versionReleaseNumber, commit, taghealthchat-v<ver>. The new build supersedes the bad one on the internal track. - Bad release notes only → edit
HEALTHCHAT_CHANGES.mdonrelease/healthchat, then update the "What's new" text in the Play Console release manually (the pipeline only writes it at upload time). - Completely wrong version number → treat as burned. Bump
versionReleaseNumberfurther, tag a higherhealthchat-v<ver>. Play Store'sversionCoderule means the next run naturally supersedes. - Production rollout gone wrong → Play Console → Production → Halt rollout (works during staged rollout), then ship a hotfix as above.
Known branch divergence when merging upstream
When merging a newer upstream Element X release tag into release/healthchat
(temporarily add https://github.com/element-hq/element-x-android as a
remote, fetch, merge, remove the remote — never keep a permanent upstream
remote):
.github/workflows/healthchat-release-playstore.yml,HEALTHCHAT_CHANGES.md, and.claude/skills/releasing-healthchat/SKILL.mdare HealthChat-only — any merge conflict here means something upstream accidentally overlaps; keep--ours.plugins/src/main/kotlin/config/BuildTimeConfig.kt—APPLICATION_ID = "ch.healthchat.element",APPLICATION_NAME = "HealthChat",METADATA_HOST_REVERSED, and the healthchat.ch URLs must survive merges. Upstream usesio.element.android.x/Element X/ nulls.appconfig/.../ApplicationConfig.kt—PRODUCTION_APPLICATION_NAME/DESKTOP_APPLICATION_NAME = "HealthChat"must survive. Upstream:Element.features/enterprise/impl-foss/.../DefaultEnterpriseService.kt—defaultHomeserverList() = listOf("matrix.healthchat.ch")must survive. Upstream returns an empty list.app/build.gradle.kts— thehealthchatsigning config and theappicon/healthchatdependency must survive; upstream signs release with the debug keystore and depends onappicon/element.app/src/gplay/AndroidManifest.xml— HealthChat-only file that stripsREQUEST_INSTALL_PACKAGESand the location permissions (FOREGROUND_SERVICE_LOCATION,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION, plusLiveLocationSharingService) from the Play build. Play Console rejects the release at edit-commit time while either is declared but not justified on the listing. Upstream has noapp/src/gplay/manifest, so this should never conflict. If HealthChat ever ships maps (a MapTiler key inBuildTimeConfig), the location removals must come out first — the feature is gated on that key, not on these declarations.plugins/src/main/kotlin/Versions.kt— take upstream's version when higher (see Versioning above).fastlane/metadata/android/— HealthChat listing texts (en-US, de-DE) must survive; upstream ships Element X texts. The per-versionCode changelog files generated by this pipeline carry HealthChat text; conflicts there mean a versionCode collided — regenerate fromHEALTHCHAT_CHANGES.mdon the next release run rather than hand-merging.