name: Make Release on: workflow_dispatch: inputs: dry_run: description: "If true, simulate the release without building, uploading, promoting, or creating a GitHub release" required: false default: "false" type: choice options: ["false", "true"] pr_number: description: "Optional PR number to comment on with dry-run readiness summary" required: false push: tags: - 'v*' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: write pull-requests: read id-token: write attestations: write jobs: prepare-build-info: runs-on: ubuntu-latest outputs: APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }} FINAL_VERSION_CODE: ${{ steps.final_version_code.outputs.FINAL_VERSION_CODE }} HOTFIX_PATCH: ${{ steps.is_hotfix_patch.outputs.hotfix_patch }} BASE_TAG: ${{ steps.get_base_tag.outputs.BASE_TAG }} FULL_TAG: ${{ steps.get_full_tag.outputs.FULL_TAG }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'jetbrains' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} build-scan-publish: true build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' build-scan-terms-of-use-agree: 'yes' - name: Determine Version Name from Tag id: get_version_name run: echo "APP_VERSION_NAME=$(echo ${GITHUB_REF_NAME#v} | sed 's/-.*//')" >> $GITHUB_OUTPUT - name: Get Full Tag id: get_full_tag run: echo "FULL_TAG=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT - name: Get Base Tag (for release/artifact naming) id: get_base_tag run: | # Remove track/iteration suffix (e.g., -internal.1, -closed.1, -open.1) BASE_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\(-internal\.[0-9]\+\|-closed\.[0-9]\+\|-open\.[0-9]\+\)$//') echo "BASE_TAG=$BASE_TAG" >> $GITHUB_OUTPUT - name: Extract VERSION_CODE_OFFSET from config.properties id: get_version_code_offset run: | OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2) echo "VERSION_CODE_OFFSET=$OFFSET" >> $GITHUB_OUTPUT - name: Calculate Version Code from Git Commit Count id: calculate_version_code run: | COMMIT_COUNT=$(git rev-list --count HEAD) OFFSET=${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }} VERSION_CODE=$((COMMIT_COUNT + OFFSET)) echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT shell: bash # This matches the reproducible versionCode strategy: versionCode = git commit count + offset - name: Check if Hotfix or Patch id: is_hotfix_patch run: | TAG_LOWER=$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]') if [[ "$TAG_LOWER" == *"-hotfix"* || "$TAG_LOWER" == *"-patch"* ]]; then echo "hotfix_patch=true" >> $GITHUB_OUTPUT else echo "hotfix_patch=false" >> $GITHUB_OUTPUT fi - name: Download Version Code Artifact (if exists) id: try_download_version_code continue-on-error: true uses: actions/download-artifact@v5 with: name: version-code path: . - name: Generate and Store Version Code (first build for regular release) if: steps.is_hotfix_patch.outputs.hotfix_patch == 'false' && steps.try_download_version_code.outcome != 'success' id: generate_and_store_version_code run: | VERSION_CODE=${{ steps.calculate_version_code.outputs.versionCode }} echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT echo "$VERSION_CODE" > version_code.txt - name: Upload Version Code Artifact (if generated) if: | (steps.is_hotfix_patch.outputs.hotfix_patch == 'true') || (steps.is_hotfix_patch.outputs.hotfix_patch == 'false' && steps.generate_and_store_version_code.conclusion == 'success') uses: actions/upload-artifact@v4 with: name: version-code path: version_code.txt - name: Set Version Code from Artifact (if exists) if: steps.try_download_version_code.outcome == 'success' id: set_version_code_from_artifact run: | VERSION_CODE=$(cat version_code.txt) echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT - name: Set Final Version Code Output id: final_version_code run: | if [ -f version_code.txt ]; then FV=$(cat version_code.txt) else FV=${{ steps.calculate_version_code.outputs.versionCode }} fi echo "FINAL_VERSION_CODE=$FV" >> $GITHUB_OUTPUT check-internal-release: runs-on: ubuntu-latest needs: prepare-build-info outputs: exists: ${{ steps.check_release.outputs.exists }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 - name: Check for existing GitHub release id: check_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BASE_TAG=${{ needs.prepare-build-info.outputs.BASE_TAG }} COMMIT_SHA=$(git rev-parse HEAD) EXISTING_RELEASE=$(gh release list --limit 100 --json tagName,targetCommitish | jq -r --arg BASE_TAG "$BASE_TAG" --arg COMMIT_SHA "$COMMIT_SHA" '.[] | select(.tagName == $BASE_TAG and .targetCommitish.oid == $COMMIT_SHA)') if [ -n "$EXISTING_RELEASE" ]; then echo "An existing release with tag '${BASE_TAG}' was found for this commit." echo "exists=true" >> $GITHUB_OUTPUT else echo "No existing release found for this commit." echo "exists=false" >> $GITHUB_OUTPUT fi release-google: runs-on: ubuntu-latest needs: [prepare-build-info, check-internal-release] outputs: INTERNAL_VERSION_CODE: ${{ steps.resolve_internal_version_code.outputs.INTERNAL_VERSION_CODE }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: '21' distribution: 'jetbrains' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} build-scan-publish: true build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service' build-scan-terms-of-use-agree: 'yes' - name: Load secrets env: GSERVICES: ${{ secrets.GSERVICES }} KEYSTORE: ${{ secrets.KEYSTORE }} KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }} KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }} run: | rm -f ./app/google-services.json # Ensure clean state echo $GSERVICES > ./app/google-services.json echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME echo "$KEYSTORE_PROPERTIES" > ./keystore.properties echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> ./secrets.properties echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: ruby-version: '3.2' bundler-cache: true - name: Dry Run Sanity Version Code Check if: github.event.inputs.dry_run == 'true' id: dry_run_sanity run: | set -euo pipefail echo "Performing version code sanity check (dry run)..." # Query highest existing version code across tracks bundle exec fastlane get_highest_version_code || true HIGHEST=$(cat highest_version_code.txt 2>/dev/null || echo 0) HOTFIX='${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}' if [ "$HOTFIX" = "true" ]; then PLANNED=$((HIGHEST + 1)) echo "Hotfix planned versionCode: $PLANNED (highest existing: $HIGHEST)"; STATUS=ok else # Regular base release planned version code (commit count + offset or reused artifact) PLANNED='${{ needs.prepare-build-info.outputs.FINAL_VERSION_CODE }}' if [ -z "$PLANNED" ]; then PLANNED=0; fi if [ "$PLANNED" -le "$HIGHEST" ]; then echo "ERROR: Planned versionCode $PLANNED is not greater than highest existing $HIGHEST. Adjust VERSION_CODE_OFFSET or convert to hotfix." >&2 STATUS=fail else echo "Planned versionCode $PLANNED is greater than existing $HIGHEST: OK"; STATUS=ok fi fi echo "sanity_status=$STATUS" >> $GITHUB_OUTPUT echo "sanity_highest=$HIGHEST" >> $GITHUB_OUTPUT echo "sanity_planned=$PLANNED" >> $GITHUB_OUTPUT # Promotion policy validation (if this is a promotion tag in dry run) TAG='${{ github.ref_name }}' if echo "$TAG" | grep -Eq '-(closed|open)$' || [[ "$TAG" != *"-internal"* && "$TAG" != *"-closed"* && "$TAG" != *"-open"* && "$TAG" == v* ]]; then echo "Checking promotion policy (dry run)..." if ! bundle exec fastlane get_internal_track_version_code; then echo "ERROR: Promotion attempted but no internal artifact present." >&2 echo "promotion_status=fail" >> $GITHUB_OUTPUT [ "$STATUS" = ok ] || true STATUS=fail else echo "Internal artifact present for promotion."; echo "promotion_status=ok" >> $GITHUB_OUTPUT fi else echo "Not a promotion tag (internal build)."; echo "promotion_status=na" >> $GITHUB_OUTPUT fi if [ "$STATUS" = fail ]; then echo "Dry run sanity check failed." >&2 exit 1 fi - name: Resolve Version Code For Internal Build if: needs.check-internal-release.outputs.exists == 'false' id: resolve_internal_version_code run: | if [ "${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}" = "true" ]; then echo "Hotfix/Patch detected; querying Google Play for highest version code..." bundle exec fastlane get_highest_version_code CODE=$(cat highest_version_code.txt || echo 0) NEXT_CODE=$((CODE + 1)) # Race mitigation: re-query to ensure no concurrent allocation bundle exec fastlane get_highest_version_code NEW_HIGHEST=$(cat highest_version_code.txt || echo 0) if [ "$NEW_HIGHEST" -ge "$NEXT_CODE" ]; then echo "Detected race: highest changed from $CODE to $NEW_HIGHEST; bumping again."; NEXT_CODE=$((NEW_HIGHEST + 1)) fi echo "Using hotfix version code: $NEXT_CODE (previous highest final: $NEW_HIGHEST)" echo "INTERNAL_VERSION_CODE=$NEXT_CODE" >> $GITHUB_OUTPUT echo "VERSION_CODE=$NEXT_CODE" >> $GITHUB_ENV else BASE_CODE=${{ needs.prepare-build-info.outputs.FINAL_VERSION_CODE }} echo "Using base/internal version code: $BASE_CODE" echo "INTERNAL_VERSION_CODE=$BASE_CODE" >> $GITHUB_OUTPUT echo "VERSION_CODE=$BASE_CODE" >> $GITHUB_ENV fi - name: Build and Deploy to Internal Track if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' env: VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} VERSION_CODE: ${{ env.VERSION_CODE }} run: bundle exec fastlane internal - name: Build F-Droid (same version code) if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' env: VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} VERSION_CODE: ${{ env.VERSION_CODE }} run: bundle exec fastlane fdroid_build - name: Generate Build Metadata & Checksums if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' id: gen_metadata run: | set -euo pipefail AAB=app/build/outputs/bundle/googleRelease/app-google-release.aab APK_GOOGLE=app/build/outputs/apk/google/release/app-google-release.apk APK_FDROID=app/build/outputs/apk/fdroid/release/app-fdroid-release.apk SHA_AAB=$(sha256sum "$AAB" | cut -d' ' -f1) SHA_APK_GOOGLE=$(sha256sum "$APK_GOOGLE" | cut -d' ' -f1) SHA_APK_FDROID=$(sha256sum "$APK_FDROID" | cut -d' ' -f1) GIT_SHA=$(git rev-parse HEAD) BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) cat > build-metadata.json <> $GITHUB_ENV echo "APK_GOOGLE_SHA256=$SHA_APK_GOOGLE" >> $GITHUB_ENV echo "APK_FDROID_SHA256=$SHA_APK_FDROID" >> $GITHUB_ENV - name: Upload Build Metadata Artifact if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' uses: actions/upload-artifact@v4 with: name: build-metadata path: build-metadata.json retention-days: 7 - name: Upload F-Droid APK artifact if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' uses: actions/upload-artifact@v4 with: name: fdroid-apk path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk retention-days: 1 - name: Promotion Guard - Internal Must Exist if: steps.fastlane_lane.outputs.lane != '' && github.event.inputs.dry_run != 'true' run: | set -e echo "Validating internal track has an artifact before promotion..." if ! bundle exec fastlane get_internal_track_version_code; then echo "ERROR: No internal artifact found to promote. Ensure an internal tag was built first." >&2 exit 1 fi - name: Fetch Internal Track Version Code (for promotion) if: steps.fastlane_lane.outputs.lane != '' && github.event.inputs.dry_run != 'true' run: | bundle exec fastlane get_internal_track_version_code CODE=$(cat internal_version_code.txt) echo "INTERNAL_VERSION_CODE=$CODE" >> $GITHUB_ENV - name: Promote on Google Play if: steps.fastlane_lane.outputs.lane != '' && github.event.inputs.dry_run != 'true' env: VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} VERSION_CODE: ${{ env.INTERNAL_VERSION_CODE }} run: bundle exec fastlane ${{ steps.fastlane_lane.outputs.lane }} - name: Build Summary (Internal Build) if: needs.check-internal-release.outputs.exists == 'false' && github.event.inputs.dry_run != 'true' run: | { echo "### Internal Build Summary" echo "Version Name: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}" echo "Version Code: ${{ env.VERSION_CODE }}" echo "Base Tag: ${{ needs.prepare-build-info.outputs.BASE_TAG }}" echo "Full Tag: ${{ needs.prepare-build-info.outputs.FULL_TAG }}" echo "Hotfix/Patch: ${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}" echo "Google AAB SHA256: $AAB_SHA256" echo "Google APK SHA256: $APK_GOOGLE_SHA256" echo "F-Droid APK SHA256: $APK_FDROID_SHA256" } >> $GITHUB_STEP_SUMMARY - name: Dry Run Summary if: github.event.inputs.dry_run == 'true' run: | echo "### Release Dry Run" >> $GITHUB_STEP_SUMMARY echo "Tag: ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY echo "Base Tag: ${{ needs.prepare-build-info.outputs.BASE_TAG }}" >> $GITHUB_STEP_SUMMARY echo "Hotfix/Patch: ${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}" >> $GITHUB_STEP_SUMMARY echo "Computed Version Name: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}" >> $GITHUB_STEP_SUMMARY echo "Planned Version Code Strategy: $([[ '${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}' == 'true' ]] && echo 'highest+1 from Play' || echo 'commit-count+offset (first internal) or reuse')" >> $GITHUB_STEP_SUMMARY echo "Sanity Highest Existing VersionCode: ${{ steps.dry_run_sanity.outputs.sanity_highest }}" >> $GITHUB_STEP_SUMMARY echo "Sanity Planned VersionCode: ${{ steps.dry_run_sanity.outputs.sanity_planned }}" >> $GITHUB_STEP_SUMMARY echo "Sanity Status: ${{ steps.dry_run_sanity.outputs.sanity_status }}" >> $GITHUB_STEP_SUMMARY echo "Promotion Policy Check: ${{ steps.dry_run_sanity.outputs.promotion_status }}" >> $GITHUB_STEP_SUMMARY echo "Would build internal artifacts: $([[ '${{ needs.check-internal-release.outputs.exists }}' == 'false' ]] && echo yes || echo 'no (already exists)')" >> $GITHUB_STEP_SUMMARY echo "Would promote lane: $([[ -n '${{ steps.fastlane_lane.outputs.lane }}' ]] && echo '${{ steps.fastlane_lane.outputs.lane }}' || echo 'n/a')" >> $GITHUB_STEP_SUMMARY echo "Would create or update draft GitHub release: $([[ '${{ needs.check-internal-release.outputs.exists }}' == 'false' ]] && echo yes || echo no)" >> $GITHUB_STEP_SUMMARY - name: Post Dry Run PR Comment if: github.event.inputs.dry_run == 'true' && github.event.inputs.pr_number != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BODY=$(cat <<'EOT' Release Dry Run Summary ---------------------- Tag: ${{ github.ref_name }} Base Tag: ${{ needs.prepare-build-info.outputs.BASE_TAG }} Version Name: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} Hotfix/Patch: ${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }} Planned VersionCode: ${{ steps.dry_run_sanity.outputs.sanity_planned }} (highest existing: ${{ steps.dry_run_sanity.outputs.sanity_highest }}) Sanity Status: ${{ steps.dry_run_sanity.outputs.sanity_status }} Promotion Policy: ${{ steps.dry_run_sanity.outputs.promotion_status }} Would Promote Lane: $([[ -n '${{ steps.fastlane_lane.outputs.lane }}' ]] && echo '${{ steps.fastlane_lane.outputs.lane }}' || echo 'n/a') Draft Release Action: $([[ '${{ needs.check-internal-release.outputs.exists }}' == 'false' ]] && echo 'would create/update' || echo 'none') EOT ) gh pr comment ${{ github.event.inputs.pr_number }} --body "$BODY" || echo "Failed to post PR comment (verify pr_number)" manage-github-release: if: github.event.inputs.dry_run != 'true' runs-on: ubuntu-latest needs: [prepare-build-info, check-internal-release, release-google] steps: - name: Download all artifacts if: needs.check-internal-release.outputs.exists == 'false' uses: actions/download-artifact@v5 with: path: ./artifacts - name: Compute Release Name (Channel Aware) id: release_name run: | BASE='${{ needs.prepare-build-info.outputs.BASE_TAG }}' REF='${{ github.ref_name }}' if [[ "$REF" == *"-internal"* ]]; then NAME="$BASE (internal)" elif [[ "$REF" == *"-closed"* ]]; then NAME="$BASE (closed testing)" elif [[ "$REF" == *"-open"* ]]; then NAME="$BASE (open beta)" else NAME="$BASE" fi echo "Computed release name: $NAME" echo "name=$NAME" >> $GITHUB_OUTPUT - name: Create GitHub Release if: needs.check-internal-release.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }} name: ${{ steps.release_name.outputs.name }} generate_release_notes: true files: ./artifacts/*/* draft: true prerelease: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Determine Release Properties for Promotion if: "!contains(github.ref_name, '-internal')" id: release_properties run: | TAG_NAME="${{ github.ref_name }}" if [[ "$TAG_NAME" == *"-closed"* ]]; then echo "draft=false" >> $GITHUB_OUTPUT echo "prerelease=true" >> $GITHUB_OUTPUT elif [[ "$TAG_NAME" == *"-open"* ]]; then echo "draft=false" >> $GITHUB_OUTPUT echo "prerelease=true" >> $GITHUB_OUTPUT else echo "draft=false" >> $GITHUB_OUTPUT echo "prerelease=false" >> $GITHUB_OUTPUT fi - name: Promote GitHub Release if: "!contains(github.ref_name, '-internal')" uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }} name: ${{ steps.release_name.outputs.name }} draft: ${{ steps.release_properties.outputs.draft }} prerelease: ${{ steps.release_properties.outputs.prerelease }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append Metadata to Release Notes if: needs.check-internal-release.outputs.exists == 'false' run: | if [ -f artifacts/build-metadata/build-metadata.json ]; then echo "\n---\nBuild Metadata JSON:\n" > appended_notes.txt cat artifacts/build-metadata/build-metadata.json >> appended_notes.txt gh release edit ${{ needs.prepare-build-info.outputs.BASE_TAG }} --notes-file appended_notes.txt fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}