name: HealthChat Release to Google Play Store on: push: tags: - 'healthchat-v*' workflow_dispatch: inputs: version: description: 'Marketing version (e.g. 26.07.0). Required when running manually.' required: true type: string track: description: 'Play Store track' required: true default: 'internal' type: choice options: - internal - alpha - beta - production release_status: description: 'Release status' required: true default: 'completed' type: choice options: - completed - inProgress - halted - draft release_notes_override: description: 'Optional release notes. If empty, falls back to HEALTHCHAT_CHANGES.md section for the given version.' required: false type: string permissions: # Only need read access to clone. Upload uses the Play Console service # account JSON from secrets, not GITHUB_TOKEN. contents: read concurrency: group: healthchat-release-${{ github.event.inputs.version || github.ref_name }} cancel-in-progress: true env: # Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the # 4-core/16GB ubuntu-latest that public repos get. On this container the # daemon keeps getting SIGKILLed mid-build (DaemonDisappearedException, no # OutOfMemoryError, no heap dump), so stay well under upstream's ceiling and # leave room for the Kotlin daemon and the parallel lint workers. GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache jobs: release: name: Build and upload AAB to Play Store runs-on: novaloop-incus-large steps: - uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false # Tag pushes must originate from release/healthchat. Dispatch runs must be # invoked from that branch too. Reject anything else up-front so we don't # ship off main/develop. - name: Verify ref is on release/healthchat run: | set -euo pipefail # On tag pushes, GITHUB_REF_NAME is the tag, not the branch. Resolve # the commit's branch membership instead. if ! git branch -r --contains "$GITHUB_SHA" | grep -qE 'origin/release/healthchat$'; then echo "::error::Commit $GITHUB_SHA is not on origin/release/healthchat. Refusing to release." exit 1 fi - name: Resolve marketing version id: version run: | set -euo pipefail if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then VERSION="${{ github.event.inputs.version }}" else VERSION="${GITHUB_REF_NAME#healthchat-v}" fi if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "::error::Invalid marketing version '$VERSION' — expected e.g. 26.07.0" exit 1 fi # Drop any pre-release/build suffix (e.g. -rc1, -hotfix1) for the Versions.kt comparison. BASE_VERSION=$(echo "$VERSION" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+)([.-].*)?$/\1/') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" echo "Resolved marketing version: $VERSION (base: $BASE_VERSION)" - name: Verify Versions.kt matches tag id: gradle_version env: BASE_VERSION: ${{ steps.version.outputs.base_version }} run: | set -euo pipefail VERSIONS_FILE=plugins/src/main/kotlin/Versions.kt YEAR=$(grep -E 'private const val versionYear' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/') MONTH=$(grep -E 'private const val versionMonth' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/') REL=$(grep -E 'private const val versionReleaseNumber' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/') if [[ -z "$YEAR" || -z "$MONTH" || -z "$REL" ]]; then echo "::error::Could not parse versionYear/versionMonth/versionReleaseNumber from $VERSIONS_FILE" exit 1 fi # VERSION_NAME format (see Versions.kt): year on 2 digits, month zero-padded to 2, release number unpadded. GRADLE_VERSION="${YEAR}.$(printf '%02d' "$MONTH").${REL}" echo "Parsed $VERSIONS_FILE: $GRADLE_VERSION" if [[ "$GRADLE_VERSION" != "$BASE_VERSION" ]]; then echo "::error::Tag version $BASE_VERSION does not match Versions.kt ($GRADLE_VERSION). Bump versionYear/versionMonth/versionReleaseNumber and commit before tagging." exit 1 fi # Formula (see Versions.kt VERSION_CODE): (2000+year)*10_000 + month*100 + releaseNumber, # then *10 for the AAB output (ABI code 0 — see app/build.gradle.kts androidComponents block). BASE_CODE=$(( (2000 + YEAR) * 10000 + MONTH * 100 + REL )) VERSION_CODE=$(( BASE_CODE * 10 )) echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" echo "Computed versionCode: $VERSION_CODE" - name: Extract release notes id: notes env: VERSION: ${{ steps.version.outputs.base_version }} OVERRIDE: ${{ github.event.inputs.release_notes_override }} run: | set -euo pipefail if [[ -n "${OVERRIDE:-}" ]]; then NOTES="$OVERRIDE" echo "Using release notes from workflow input" else NOTES=$(awk -v ver="$VERSION" ' /^## Changes in / { if (found) { exit } if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next } } found { print } ' HEALTHCHAT_CHANGES.md | sed '/^[[:space:]]*$/{N;/^\n$/D;}') if [[ -z "$NOTES" ]]; then NOTES="HealthChat ${VERSION} — automated build from ${GITHUB_REF_NAME}." echo "::warning::No HEALTHCHAT_CHANGES.md section found for $VERSION — using fallback" fi fi # Play Store "What's new" has a 500-character limit per locale. CHAR_COUNT=$(printf '%s' "$NOTES" | wc -c) if (( CHAR_COUNT > 500 )); then echo "::warning::Release notes are $CHAR_COUNT chars; Play Store truncates at 500" fi { echo 'notes<> "$GITHUB_OUTPUT" - name: Generate fastlane changelog files env: VERSION_CODE: ${{ steps.gradle_version.outputs.version_code }} NOTES: ${{ steps.notes.outputs.notes }} run: | set -euo pipefail for locale in en-US de-DE; do dir="fastlane/metadata/android/${locale}/changelogs" mkdir -p "$dir" printf '%s\n' "$NOTES" > "${dir}/${VERSION_CODE}.txt" echo "Wrote ${dir}/${VERSION_CODE}.txt" done - name: Use JDK 21 uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '21' # The self-hosted runner image has no Android SDK (unlike GitHub-hosted # ubuntu-latest), so install it here. # - platform: follows COMPILE_SDK in plugins/src/main/kotlin/Versions.kt. # Platform packages are minor-versioned, so compileSdk 37 is android-37.0. # - build-tools: nothing sets buildToolsVersion, so AGP picks its own # default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION). Bump it when AGP is # upgraded, not when Versions.kt changes. - name: Set up Android SDK uses: android-actions/setup-android@v3 with: packages: 'platforms;android-37.0 build-tools;36.0.0' - name: Configure gradle uses: gradle/actions/setup-gradle@v4 - name: Decode keystore run: | echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > app/signature/release.keystore # Recorded on every run so build failures can be read against the # runner's actual capacity instead of guessed at. - name: Report runner capacity run: | free -h nproc df -h . # free reads /proc/meminfo, which in a container may report the host's # total rather than the enforced limit. This is what the kernel enforces. cat /sys/fs/cgroup/memory.max 2>/dev/null \ || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \ || echo "no cgroup memory limit visible" - name: Build Release AAB run: | ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES env: RELEASE_STORE_FILE: signature/release.keystore RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - name: Upload workflow artifacts if: always() uses: actions/upload-artifact@v4 with: name: healthchat-${{ steps.version.outputs.version }} path: | app/build/outputs/bundle/gplayRelease/*.aab app/build/outputs/mapping/gplayRelease/mapping.txt retention-days: 90 if-no-files-found: warn - name: Upload to Play Store uses: r0adkll/upload-google-play@v1 with: serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} packageName: ch.healthchat.element releaseFiles: app/build/outputs/bundle/gplayRelease/*.aab tracks: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.track || 'internal' }} status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }} whatsNewDirectory: fastlane/metadata/android mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt changesNotSentForReview: true