From 7bc9469df5f327919768552cd3efd8c6e896dc2b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 3 Oct 2025 09:33:09 -0500 Subject: [PATCH] feat(ci): overhaul release workflow for hotfixes and promotions (#3307) --- .github/workflows/create-internal-release.yml | 139 ++++++ .github/workflows/promote-release.yml | 191 ++++++++ .github/workflows/release.yml | 452 ++++++++++++++---- RELEASE_PROCESS.md | 25 + fastlane/Fastfile | 70 ++- 5 files changed, 760 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/create-internal-release.yml create mode 100644 .github/workflows/promote-release.yml diff --git a/.github/workflows/create-internal-release.yml b/.github/workflows/create-internal-release.yml new file mode 100644 index 000000000..68be90ae5 --- /dev/null +++ b/.github/workflows/create-internal-release.yml @@ -0,0 +1,139 @@ +name: Create Internal Release Tag + +on: + workflow_dispatch: + inputs: + release_type: + description: "Type of release (auto|patch|minor|major|hotfix)" + required: true + default: auto + type: choice + options: [auto, patch, minor, major, hotfix] + hotfix_base_version: + description: "Base version for hotfix (e.g. 2.5.0) required if release_type=hotfix" + required: false + dry_run: + description: "If true, calculate but do not push tag" + required: false + default: "false" + +permissions: + contents: write + +jobs: + create-internal-tag: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Determine Latest Base Version + id: latest + run: | + set -euo pipefail + # List base tags (exclude track/hotfix suffixes) + BASE_TAGS=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n1 || true) + echo "Found latest base tag: $BASE_TAGS" + echo "latest_base_tag=$BASE_TAGS" >> $GITHUB_OUTPUT + + - name: Compute Next Version (auto/patch/minor/major) + id: compute + if: ${{ inputs.release_type != 'hotfix' }} + run: | + set -euo pipefail + RTYPE='${{ inputs.release_type }}' + LAST='${{ steps.latest.outputs.latest_base_tag }}' + if [ -z "$LAST" ]; then + BASE_MAJOR=0; BASE_MINOR=0; BASE_PATCH=0 + else + V=${LAST#v} + IFS='.' read -r BASE_MAJOR BASE_MINOR BASE_PATCH <<< "$V" + fi + if [ "$RTYPE" = 'auto' ]; then + echo "Determining bump type from commits since $LAST..." + RANGE="$LAST..HEAD" + [ -z "$LAST" ] && RANGE="HEAD" # first release + LOG=$(git log --format=%s $RANGE || true) + BUMP="patch" + if echo "$LOG" | grep -Eiq 'BREAKING CHANGE'; then BUMP=major; fi + if echo "$LOG" | grep -Eiq '^[a-zA-Z]+!:'; then BUMP=major; fi + if [ "$BUMP" != major ] && echo "$LOG" | grep -Eiq '^feat(\(|:)' ; then BUMP=minor; fi + RTYPE=$BUMP + echo "Auto-detected bump: $RTYPE" + fi + case "$RTYPE" in + major) + NEW_MAJOR=$((BASE_MAJOR+1)); NEW_MINOR=0; NEW_PATCH=0;; + minor) + NEW_MAJOR=$BASE_MAJOR; NEW_MINOR=$((BASE_MINOR+1)); NEW_PATCH=0;; + patch) + NEW_MAJOR=$BASE_MAJOR; NEW_MINOR=$BASE_MINOR; NEW_PATCH=$((BASE_PATCH+1));; + *) echo "Unsupported release_type for this step: $RTYPE"; exit 1;; + esac + NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + echo "base_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Compute Hotfix Version + id: hotfix + if: ${{ inputs.release_type == 'hotfix' }} + run: | + set -euo pipefail + BASE='${{ inputs.hotfix_base_version }}' + if [ -z "$BASE" ]; then + echo "hotfix_base_version required for hotfix release_type" >&2 + exit 1 + fi + if ! git tag --list | grep -q "^v$BASE$"; then + echo "Base version tag v$BASE not found (production tag required)." >&2 + exit 1 + fi + EXISTING=$(git tag --list "v${BASE}-hotfix*" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+-hotfix([0-9]+).*$/\1/' | sort -n | tail -1 || true) + if [ -z "$EXISTING" ]; then NEXT=1; else NEXT=$((EXISTING+1)); fi + HOTFIX_VERSION="${BASE}-hotfix${NEXT}" + echo "hotfix_version=$HOTFIX_VERSION" >> $GITHUB_OUTPUT + + - name: Decide Internal Tag + id: tag + run: | + set -euo pipefail + if [ '${{ inputs.release_type }}' = 'hotfix' ]; then + BASE='${{ steps.hotfix.outputs.hotfix_version }}' + else + BASE='${{ steps.compute.outputs.base_version }}' + fi + INTERNAL_TAG="v${BASE}-internal.1" + if git tag --list | grep -q "^${INTERNAL_TAG}$"; then + echo "Tag ${INTERNAL_TAG} already exists." >&2 + exit 1 + fi + echo "internal_tag=$INTERNAL_TAG" >> $GITHUB_OUTPUT + + - name: Dry Run Preview + if: ${{ inputs.dry_run == 'true' }} + run: | + echo "DRY RUN: Would create tag ${{ steps.tag.outputs.internal_tag }} pointing to $(git rev-parse HEAD)" + git log -5 --oneline + + - name: Create and Push Tag + if: ${{ inputs.dry_run != 'true' }} + run: | + TAG='${{ steps.tag.outputs.internal_tag }}' + MSG="Initial internal build for ${TAG}" + git tag -a "$TAG" -m "$MSG" + git push origin "$TAG" + echo "Created and pushed $TAG" + + - name: Output Summary + run: | + echo "### Internal Tag Created" >> $GITHUB_STEP_SUMMARY + echo "Tag: ${{ steps.tag.outputs.internal_tag }}" >> $GITHUB_STEP_SUMMARY + echo "Release Type: ${{ inputs.release_type }}" >> $GITHUB_STEP_SUMMARY + if [ '${{ inputs.release_type }}' = 'hotfix' ]; then + echo "Base Hotfix Series: ${{ steps.hotfix.outputs.hotfix_version }}" >> $GITHUB_STEP_SUMMARY + else + echo "Base Version: ${{ steps.compute.outputs.base_version }}" >> $GITHUB_STEP_SUMMARY + fi + echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY + diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 000000000..04d42bb25 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,191 @@ +name: Promote Release + +on: + workflow_dispatch: + inputs: + target_stage: + description: "Stage to promote to (auto|closed|open|production)" + required: true + default: auto + type: choice + options: [auto, closed, open, production] + base_version: + description: "Explicit base version (e.g. 2.5.0 or 2.5.0-hotfix1). If omitted, latest internal tag base is used." + required: false + allow_skip: + description: "Allow skipping intermediate stages (e.g. internal->production)" + required: false + default: "false" + dry_run: + description: "If true, only compute next tag; don't push" + required: false + default: "false" + +permissions: + contents: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Determine Base Version + id: base + run: | + set -euo pipefail + INPUT_BASE='${{ inputs.base_version }}' + if [ -n "$INPUT_BASE" ]; then + # Validate an internal tag exists for provided base + if ! git tag --list | grep -q "^v${INPUT_BASE}-internal\."; then + echo "No internal tag found for base version v${INPUT_BASE}." >&2 + exit 1 + fi + BASE_VERSION="$INPUT_BASE" + else + LATEST_INTERNAL_TAG=$(git tag --list 'v*-internal.*' --sort=-taggerdate | head -n1 || true) + if [ -z "$LATEST_INTERNAL_TAG" ]; then + echo "No internal tags found; nothing to promote." >&2 + exit 1 + fi + # Strip leading v and suffix -internal.N + BASE_VERSION=$(echo "$LATEST_INTERNAL_TAG" | sed -E 's/^v(.*)-internal\.[0-9]+$/\1/') + fi + echo "Base version: $BASE_VERSION" + echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT + + - name: Gather Existing Stage Tags + id: scan + run: | + set -euo pipefail + BASE='${{ steps.base.outputs.base_version }}' + INTERNAL_TAGS=$(git tag --list "v${BASE}-internal.*" | sort -V || true) + CLOSED_TAGS=$(git tag --list "v${BASE}-closed.*" | sort -V || true) + OPEN_TAGS=$(git tag --list "v${BASE}-open.*" | sort -V || true) + PROD_TAG=$(git tag --list "v${BASE}" || true) + echo "internal_tags<> $GITHUB_OUTPUT + echo "$INTERNAL_TAGS" >> $GITHUB_OUTPUT + echo EOF >> $GITHUB_OUTPUT + echo "closed_tags<> $GITHUB_OUTPUT + echo "$CLOSED_TAGS" >> $GITHUB_OUTPUT + echo EOF >> $GITHUB_OUTPUT + echo "open_tags<> $GITHUB_OUTPUT + echo "$OPEN_TAGS" >> $GITHUB_OUTPUT + echo EOF >> $GITHUB_OUTPUT + if [ -n "$PROD_TAG" ]; then echo "production_present=true" >> $GITHUB_OUTPUT; else echo "production_present=false" >> $GITHUB_OUTPUT; fi + if [ -z "$INTERNAL_TAGS" ]; then + echo "No internal tags found for base version $BASE." >&2 + exit 1 + fi + + - name: Determine Current Stage + id: current + run: | + set -euo pipefail + PROD='${{ steps.scan.outputs.production_present }}' + CLOSED='${{ steps.scan.outputs.closed_tags }}' + OPEN='${{ steps.scan.outputs.open_tags }}' + if [ "$PROD" = 'true' ]; then CUR=production + elif [ -n "$OPEN" ]; then CUR=open + elif [ -n "$CLOSED" ]; then CUR=closed + else CUR=internal; fi + echo "Current highest stage: $CUR" + echo "current_stage=$CUR" >> $GITHUB_OUTPUT + + - name: Decide Target Stage + id: decide + run: | + set -euo pipefail + REQ='${{ inputs.target_stage }}' + CUR='${{ steps.current.outputs.current_stage }}' + ALLOW_SKIP='${{ inputs.allow_skip }}' + order=(internal closed open production) + # helper to get index + idx() { local i=0; for s in "${order[@]}"; do [ "$s" = "$1" ] && echo $i && return; i=$((i+1)); done; echo -1; } + if [ "$REQ" = auto ]; then + CUR_IDX=$(idx "$CUR") + TARGET_IDX=$((CUR_IDX+1)) + TARGET_STAGE=${order[$TARGET_IDX]:-} + if [ -z "$TARGET_STAGE" ]; then + echo "Already at production; nothing to promote." >&2 + exit 1 + fi + else + TARGET_STAGE=$REQ + CUR_IDX=$(idx "$CUR") + REQ_IDX=$(idx "$TARGET_STAGE") + if [ $REQ_IDX -le $CUR_IDX ]; then + echo "Requested stage $TARGET_STAGE is not ahead of current stage $CUR." >&2 + exit 1 + fi + if [ "$ALLOW_SKIP" != 'true' ] && [ $((CUR_IDX+1)) -ne $REQ_IDX ]; then + echo "Skipping stages not allowed (current=$CUR, requested=$TARGET_STAGE). Enable allow_skip to override." >&2 + exit 1 + fi + fi + echo "Target stage: $TARGET_STAGE" + echo "target_stage=$TARGET_STAGE" >> $GITHUB_OUTPUT + + - name: Compute New Tag + id: tag + run: | + set -euo pipefail + BASE='${{ steps.base.outputs.base_version }}' + TARGET='${{ steps.decide.outputs.target_stage }}' + if [ "$TARGET" = production ]; then + NEW_TAG="v${BASE}" + if git tag --list | grep -q "^${NEW_TAG}$"; then + echo "Production tag ${NEW_TAG} already exists." >&2 + exit 1 + fi + else + EXISTING=$(git tag --list "v${BASE}-${TARGET}.*" | sed -E "s/^v.*-${TARGET}\.([0-9]+)$/\1/" | sort -n | tail -1 || true) + if [ -z "$EXISTING" ]; then NEXT=1; else NEXT=$((EXISTING+1)); fi + NEW_TAG="v${BASE}-${TARGET}.${NEXT}" + fi + echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT + echo "Will create tag: $NEW_TAG" + + - name: Resolve Commit to Tag (latest internal for base) + id: commit + run: | + set -euo pipefail + BASE='${{ steps.base.outputs.base_version }}' + LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1) + if [ -z "$LATEST_INTERNAL" ]; then + echo "No internal tag found for base $BASE (unexpected)." >&2 + exit 1 + fi + COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL") + echo "commit_sha=$COMMIT" >> $GITHUB_OUTPUT + echo "Using commit $COMMIT from $LATEST_INTERNAL" + + - name: Dry Run Summary + if: ${{ inputs.dry_run == 'true' }} + run: | + echo "DRY RUN: Would tag commit ${{ steps.commit.outputs.commit_sha }} with ${{ steps.tag.outputs.new_tag }}" + echo "Current stage: ${{ steps.current.outputs.current_stage }} -> Target: ${{ steps.decide.outputs.target_stage }}" + git log -1 --oneline ${{ steps.commit.outputs.commit_sha }} + + - name: Create & Push Tag + if: ${{ inputs.dry_run != 'true' }} + run: | + TAG='${{ steps.tag.outputs.new_tag }}' + COMMIT='${{ steps.commit.outputs.commit_sha }}' + MSG="Promote ${TAG} from ${{ steps.current.outputs.current_stage }} to ${{ steps.decide.outputs.target_stage }}" + git tag -a "$TAG" "$COMMIT" -m "$MSG" + git push origin "$TAG" + echo "Created and pushed $TAG" + + - name: Promotion Summary + run: | + echo "### Promotion Tag Created" >> $GITHUB_STEP_SUMMARY + echo "Base Version: ${{ steps.base.outputs.base_version }}" >> $GITHUB_STEP_SUMMARY + echo "Current Stage: ${{ steps.current.outputs.current_stage }}" >> $GITHUB_STEP_SUMMARY + echo "Target Stage: ${{ steps.decide.outputs.target_stage }}" >> $GITHUB_STEP_SUMMARY + echo "New Tag: ${{ steps.tag.outputs.new_tag }}" >> $GITHUB_STEP_SUMMARY + echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06d052739..b3606206e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,16 @@ 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*' @@ -21,7 +31,10 @@ jobs: runs-on: ubuntu-latest outputs: APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }} - APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} + 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 @@ -45,6 +58,17 @@ jobs: 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: | @@ -61,9 +85,89 @@ jobs: shell: bash # This matches the reproducible versionCode strategy: versionCode = git commit count + offset - release-google: + - 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 @@ -76,7 +180,6 @@ jobs: java-version: '21' distribution: 'jetbrains' - name: Setup Gradle - if: contains(github.ref_name, '-internal') uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -110,121 +213,258 @@ jobs: ruby-version: '3.2' bundler-cache: true - - name: Determine Fastlane Lane - id: fastlane_lane + - name: Dry Run Sanity Version Code Check + if: github.event.inputs.dry_run == 'true' + id: dry_run_sanity run: | - TAG_NAME="${{ github.ref_name }}" - if [[ "$TAG_NAME" == *"-internal"* ]]; then - echo "lane=internal" >> $GITHUB_OUTPUT - elif [[ "$TAG_NAME" == *"-closed"* ]]; then - echo "lane=closed" >> $GITHUB_OUTPUT - elif [[ "$TAG_NAME" == *"-open"* ]]; then - echo "lane=open" >> $GITHUB_OUTPUT + 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 - echo "lane=production" >> $GITHUB_OUTPUT + # 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: Build and Deploy Google Play Tracks with Fastlane - env: - VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - run: bundle exec fastlane ${{ steps.fastlane_lane.outputs.lane }} - - - name: Upload Google AAB artifact - if: contains(github.ref_name, '-internal') - uses: actions/upload-artifact@v4 - with: - name: google-aab - path: app/build/outputs/bundle/googleRelease/app-google-release.aab - retention-days: 1 - - - name: Upload Google APK artifact - if: contains(github.ref_name, '-internal') - uses: actions/upload-artifact@v4 - with: - name: google-apk - path: app/build/outputs/apk/google/release/app-google-release.apk - retention-days: 1 - - - name: Attest Google artifacts provenance - if: contains(github.ref_name, '-internal') - uses: actions/attest-build-provenance@v3 - with: - subject-path: | - app/build/outputs/bundle/googleRelease/app-google-release.aab - app/build/outputs/apk/google/release/app-google-release.apk - - release-fdroid: - if: contains(github.ref_name, '-internal') - runs-on: ubuntu-latest - needs: prepare-build-info - 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: - KEYSTORE: ${{ secrets.KEYSTORE }} - KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }} - KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} + - name: Resolve Version Code For Internal Build + if: needs.check-internal-release.outputs.exists == 'false' + id: resolve_internal_version_code run: | - echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME - echo "$KEYSTORE_PROPERTIES" > ./keystore.properties + 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: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Build F-Droid with Fastlane + - 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: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} + 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: Upload F-Droid APK artifact + - 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: fdroid-apk - path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk - retention-days: 1 + name: build-metadata + path: build-metadata.json + retention-days: 7 - - name: Attest F-Droid APK provenance - uses: actions/attest-build-provenance@v3 + - 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: - subject-path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk + name: fdroid-apk + path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk + retention-days: 1 - create-internal-release: + - 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, release-google, release-fdroid] - if: contains(github.ref_name, '-internal') + 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: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - name: ${{ github.ref_name }} + tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }} + name: ${{ steps.release_name.outputs.name }} generate_release_notes: true files: ./artifacts/*/* draft: true @@ -232,12 +472,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - promote-release: - runs-on: ubuntu-latest - needs: [prepare-build-info, release-google] - if: "!contains(github.ref_name, '-internal')" - steps: - - name: Determine Release Properties + - name: Determine Release Properties for Promotion + if: "!contains(github.ref_name, '-internal')" id: release_properties run: | TAG_NAME="${{ github.ref_name }}" @@ -252,12 +488,24 @@ jobs: echo "prerelease=false" >> $GITHUB_OUTPUT fi - - name: Update GitHub Release + - name: Promote GitHub Release + if: "!contains(github.ref_name, '-internal')" uses: softprops/action-gh-release@v2 with: - tag_name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - name: ${{ github.ref_name }} + 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 }} diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 002ee6821..b202aae40 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -39,6 +39,31 @@ git tag v2.3.5-closed.1 git push origin v2.3.5-closed.1 ``` +## Hotfixes & Patch Releases + +If you need to release a hotfix or patch for a previous version (not the latest mainline), follow this process: + +- **Tagging:** Use a tag with a suffix, such as `vX.X.X-hotfix1` or `vX.X.X-patch1` (e.g., `v2.3.5-hotfix1`). +- **Uniqueness:** The release workflow uses the full tag (including suffix) for all artifact and release naming, so each hotfix/patch is uniquely identified. +- **Version Code:** The workflow automatically ensures the version code for a hotfix/patch is strictly greater than any previous release, even if the hotfix is created from an older commit. This prevents Play Store upload errors due to version code regressions. +- **Multiple Releases:** You can have multiple releases for the same base version (e.g., `v2.3.5`, `v2.3.5-hotfix1`, `v2.3.5-patch2`). Each will be published and promoted independently. + +### Hotfix Tagging Example +```bash +# On a release branch or after checking out the commit to hotfix +# Tag and push a hotfix release +git tag v2.3.5-hotfix1 +git push origin v2.3.5-hotfix1 +# For additional hotfixes/patches: +git tag v2.3.5-hotfix2 +git push origin v2.3.5-hotfix2 +``` + +### Policy +- Always use a unique tag for each hotfix/patch. +- The version code will always increase, regardless of commit history. +- The full tag is used for all release and artifact naming. + ## Manual Checklist - [ ] Verify build in Google Play Console - [ ] Review and publish GitHub draft release (for production) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 17706b512..d7b5a0a5f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -23,17 +23,25 @@ platform :android do desc "Deploy a new version to the internal track on Google Play" lane :internal do - aab_path = build_google_release - upload_to_play_store( - track: 'internal', - aab: aab_path, - release_status: 'completed', - skip_upload_apk: true, - skip_upload_metadata: true, - skip_upload_changelogs: true, - skip_upload_images: true, - skip_upload_screenshots: true, - ) + begin + aab_path = build_google_release + upload_to_play_store( + track: 'internal', + aab: aab_path, + release_status: 'completed', + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_changelogs: true, + skip_upload_images: true, + skip_upload_screenshots: true, + ) + rescue => exception + if exception.message.include?("Google Api Error: forbidden: A release with version code") && exception.message.include?("already exists for this track") + UI.message("This version code is already on the internal track. No action needed.") + else + raise exception + end + end end desc "Promote from internal track to the closed track on Google Play" @@ -41,6 +49,7 @@ platform :android do upload_to_play_store( track: 'internal', track_promote_to: 'NewAlpha', + version_codes: [ENV["VERSION_CODE"].to_i], release_status: 'completed', skip_upload_apk: true, skip_upload_metadata: true, @@ -50,11 +59,12 @@ platform :android do ) end - desc "Promote from closed track to the open track on Google Play" + desc "Promote from internal track to the open track on Google Play" lane :open do upload_to_play_store( - track: 'NewAlpha', + track: 'internal', track_promote_to: 'beta', + version_codes: [ENV["VERSION_CODE"].to_i], release_status: 'draft', skip_upload_apk: true, skip_upload_metadata: true, @@ -64,11 +74,12 @@ platform :android do ) end - desc "Promote from open track to the production track on Google Play" + desc "Promote from internal track to the production track on Google Play" lane :production do upload_to_play_store( - track: 'open', + track: 'internal', track_promote_to: 'production', + version_codes: [ENV["VERSION_CODE"].to_i], release_status: 'draft', skip_upload_apk: true, skip_upload_metadata: true, @@ -89,6 +100,35 @@ platform :android do ) end + desc "Get the highest version code from all Google Play tracks" + lane :get_highest_version_code do + require 'set' + all_codes = Set.new + tracks = ['internal', 'closed', 'open', 'production'] + tracks.each do |track| + begin + codes = google_play_track_version_codes(track: track, package_name: 'com.geeksville.mesh') + all_codes.merge(codes.map(&:to_i)) + rescue => e + UI.message("Could not fetch version codes for track #{track}: #{e.message}") + end + end + highest = all_codes.max || 0 + UI.message("Highest version code on Google Play: #{highest}") + File.write('highest_version_code.txt', highest.to_s) + end + + desc "Get the version code currently on the internal track (max if multiple)" + lane :get_internal_track_version_code do + codes = google_play_track_version_codes(track: 'internal', package_name: 'com.geeksville.mesh') + if codes.nil? || codes.empty? + UI.user_error!("No version codes found on internal track. Ensure an internal build has been published before promoting.") + end + max_code = codes.map(&:to_i).max + UI.message("Internal track version code: #{max_code}") + File.write('internal_version_code.txt', max_code.to_s) + end + private_lane :build_google_release do gradle( task: "clean bundleGoogleRelease assembleGoogleRelease",