Files
element-x-android/.claude/skills/releasing-healthchat/SKILL.md
T
joel.poyet b4629fff90 fix(release): drop changesNotSentForReview from Play upload
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.
2026-08-03 13:44:34 +02:00

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/healthchat with a clean working tree (git status).
  • HEALTHCHAT_CHANGES.md has 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.kt versionYear, versionMonth, and versionReleaseNumber produce 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.md or Versions.kt, both commits are pushed to release/healthchat before 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.

  1. GitHub → Actions → "HealthChat Release to Google Play Store" → Run workflow.
  2. Fill in:
    • version (required): e.g. 26.07.1. Same regex validation as tag path. Must still match Versions.kt.
    • track: default internal; pick alpha/beta/production to 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; use draft for dry-runs that land in Play Console but stay unpublished.
    • release_notes_override (optional): replaces the HEALTHCHAT_CHANGES.md lookup. Useful for ad-hoc test builds.
  3. 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 SDK step installs it. Platform packages are minor-versioned, so COMPILE_SDK = 37 means platforms;android-37.0. Build-tools follows AGP's own default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), not BUILD_TOOLS_VERSION in Versions.kt — nothing sets buildToolsVersion, so bump it when AGP is upgraded.
  • GRADLE_OPTS is deliberately smaller than upstream's. Upstream's workflows use -Xmx8g with a 4 GB Kotlin daemon, sized for the 4-core/16GB ubuntu-latest that 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 is DaemonDisappearedException with no OutOfMemoryError and no heap dump, which is what an external kill looks like. -Xmx6g with 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.txt are 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:

  1. 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.
  2. 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
    
  3. Once Google confirms the rotation, re-encode and update secrets: base64 -w0 healthchat-upload-new.jks | pbcopy, then replace RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD in repo settings.
  4. Run the release workflow via workflow_dispatch with a throwaway version bump and release_status: draft to 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:

  1. Play Console → HealthChat → Testing → Internal testing → find the release.
  2. Confirm it passed internal testing.
  3. 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.
  4. 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, tag healthchat-v<ver>. The new build supersedes the bad one on the internal track.
  • Bad release notes only → edit HEALTHCHAT_CHANGES.md on release/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 versionReleaseNumber further, tag a higher healthchat-v<ver>. Play Store's versionCode rule 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.md are 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 uses io.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 — the healthchat signing config and the appicon/healthchat dependency must survive; upstream signs release with the debug keystore and depends on appicon/element.
  • app/src/gplay/AndroidManifest.xml — HealthChat-only file that strips REQUEST_INSTALL_PACKAGES and the location permissions (FOREGROUND_SERVICE_LOCATION, ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, plus LiveLocationSharingService) 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 no app/src/gplay/ manifest, so this should never conflict. If HealthChat ever ships maps (a MapTiler key in BuildTimeConfig), 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 from HEALTHCHAT_CHANGES.md on the next release run rather than hand-merging.