feat(ci): overhaul release workflow for hotfixes and promotions (#3307)

pull/3301/head^2
James Rich 2025-10-03 09:33:09 -05:00 zatwierdzone przez GitHub
rodzic 87f7ea3f47
commit 7bc9469df5
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
5 zmienionych plików z 760 dodań i 117 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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<<EOF" >> $GITHUB_OUTPUT
echo "$INTERNAL_TAGS" >> $GITHUB_OUTPUT
echo EOF >> $GITHUB_OUTPUT
echo "closed_tags<<EOF" >> $GITHUB_OUTPUT
echo "$CLOSED_TAGS" >> $GITHUB_OUTPUT
echo EOF >> $GITHUB_OUTPUT
echo "open_tags<<EOF" >> $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

Wyświetl plik

@ -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 <<EOF
{
"baseTag": "${{ needs.prepare-build-info.outputs.BASE_TAG }}",
"fullTag": "${{ needs.prepare-build-info.outputs.FULL_TAG }}",
"versionName": "${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}",
"versionCode": "${{ env.VERSION_CODE }}",
"hotfixOrPatch": "${{ needs.prepare-build-info.outputs.HOTFIX_PATCH }}",
"gitSha": "$GIT_SHA",
"buildTimeUtc": "$BUILD_TIME",
"artifacts": {
"googleAab": { "path": "$AAB", "sha256": "$SHA_AAB" },
"googleApk": { "path": "$APK_GOOGLE", "sha256": "$SHA_APK_GOOGLE" },
"fdroidApk": { "path": "$APK_FDROID", "sha256": "$SHA_APK_FDROID" }
}
}
EOF
echo "Generated build-metadata.json:"; cat build-metadata.json
echo "AAB_SHA256=$SHA_AAB" >> $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 }}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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",