refactor(release)!: simplify and streamline release workflow

This commit refactors the entire release process to be simpler, more robust, and less error-prone. The previous complex system of manual version calculation, artifact passing, and dry-run flags has been replaced with a more streamlined, two-stage process driven by distinct GitHub Actions workflows.

The new process separates tag creation from the release engine. A new `create-internal-release.yml` workflow is introduced to handle the creation and iteration of internal build tags (e.g., `v1.2.3-internal.1`, `v1.2.3-internal.2`). The main `release.yml` workflow is now triggered solely by the presence of a new version tag.

Key changes include:
-   The `release.yml` workflow no longer takes `dry_run` or `pr_number` inputs, as tag creation is now handled separately.
-   Version code is now consistently calculated once per build based on Git history and an offset, removing the complex logic for handling hotfixes and reusing version codes via artifacts.
-   Builds only occur if a release for the given `BASE_TAG` doesn't already exist on the same commit. This makes the process idempotent for promotions.
-   A new `prepare-release-environment` job intelligently handles superseded releases by deleting old releases and tags if a new tag for the same base version points to a different commit.
-   The build process is split into separate jobs for Google Play (`release-google`) and F-Droid (`release-fdroid`), allowing them to run in parallel.
-   Build attestations are now generated for all artifacts to improve supply chain security.
-   The `create-internal-release.yml` workflow is simplified to only require a `base_version`, automatically calculating the next internal iteration number. It no longer handles complex version bumping logic (`major`, `minor`, `patch`, `hotfix`).
-   Fastlane scripts have been cleaned up to remove now-unused lanes (`get_internal_track_version_code`).
-   The `RELEASE_PROCESS.md` documentation has been completely rewritten to reflect the new, simpler workflow.

BREAKING CHANGE: The release workflow has been fundamentally changed.
-   The `release.yml` workflow is no longer triggered with `workflow_dispatch` inputs like `dry_run`. Releases are now initiated by pushing a correctly formatted tag.
-   The `create-internal-release.yml` workflow has been changed. It no longer supports `release_type` (`auto`, `patch`, etc.) and instead requires a specific `base_version` to create an internal build tag for.
-   Hotfix handling via special tags like `-hotfix` is removed. Superseding a release is now handled by creating a new internal iteration for the same base version from a newer commit.

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
pull/3309/head
James Rich 2025-10-03 10:43:36 -05:00
rodzic a7183cc8ca
commit b884c83980
4 zmienionych plików z 184 dodań i 493 usunięć

Wyświetl plik

@ -3,15 +3,9 @@ name: Create Internal Release Tag
on:
workflow_dispatch:
inputs:
release_type:
description: "Type of release (auto|patch|minor|major|hotfix)"
base_version:
description: "Base version to iterate on (e.g. 2.6.7). The next internal iteration will be created for this version."
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
@ -29,86 +23,21 @@ jobs:
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
- name: Compute 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
BASE='${{ inputs.base_version }}'
# Find the highest existing internal tag for this base version and increment it.
EXISTING=$(git tag --list "v${BASE}-internal.*" | sed -E 's/^v.*-internal\.([0-9]+)$/\1/' | sort -n | tail -1 || true)
if [ -z "$EXISTING" ]; then NEXT=1; else NEXT=$((EXISTING+1)); fi
FINAL_TAG="v${BASE}-internal.${NEXT}"
# Check if the tag already exists for some reason (e.g. race condition).
if git tag --list | grep -q "^${FINAL_TAG}$"; then
echo "Tag ${FINAL_TAG} already exists." >&2
exit 1
fi
echo "internal_tag=$INTERNAL_TAG" >> $GITHUB_OUTPUT
echo "internal_tag=$FINAL_TAG" >> $GITHUB_OUTPUT
- name: Dry Run Preview
if: ${{ inputs.dry_run == 'true' }}
@ -120,7 +49,7 @@ jobs:
if: ${{ inputs.dry_run != 'true' }}
run: |
TAG='${{ steps.tag.outputs.internal_tag }}'
MSG="Initial internal build for ${TAG}"
MSG="Internal build iteration for ${TAG}"
git tag -a "$TAG" -m "$MSG"
git push origin "$TAG"
echo "Created and pushed $TAG"
@ -129,11 +58,5 @@ jobs:
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 "Base Version: ${{ inputs.base_version }}" >> $GITHUB_STEP_SUMMARY
echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY

Wyświetl plik

@ -2,16 +2,6 @@ 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*'
@ -31,10 +21,8 @@ jobs:
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 }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
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
@ -58,21 +46,16 @@ 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
VERSION_NAME=$(echo ${GITHUB_REF_NAME#v} | sed 's/-.*//')
echo "BASE_TAG=v${VERSION_NAME}" >> $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)
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
@ -83,91 +66,40 @@ jobs:
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:
prepare-release-environment:
runs-on: ubuntu-latest
needs: prepare-build-info
outputs:
exists: ${{ steps.check_release.outputs.exists }}
exists: ${{ steps.check_and_clean.outputs.exists }}
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check for existing GitHub release
id: check_release
- name: Check for Existing Release and Clean if Superseded
id: check_and_clean
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."
RELEASE_INFO=$(gh release view $BASE_TAG --json targetCommitish -q ".targetCommitish.oid" || echo "")
if [ -z "$RELEASE_INFO" ]; then
echo "No existing release for tag '${BASE_TAG}'. Starting fresh."
echo "exists=false" >> $GITHUB_OUTPUT
elif [ "$RELEASE_INFO" == "$COMMIT_SHA" ]; then
echo "Existing release for '${BASE_TAG}' found on the current commit. This is a promotion."
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "No existing release found for this commit."
echo "Existing release for '${BASE_TAG}' found on a DIFFERENT commit ($RELEASE_INFO)."
echo "This new tag supersedes the old one. Deleting old release and tag to restart the process."
gh release delete $BASE_TAG --cleanup-tag --yes || echo "Could not delete release. It might have been deleted already."
echo "Old release and tag deleted. A new build will be created."
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 }}
needs: [prepare-build-info, prepare-release-environment]
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -213,258 +145,129 @@ jobs:
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'
if: needs.prepare-release-environment.outputs.exists == 'false'
env:
VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ env.VERSION_CODE }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_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
- name: Determine Fastlane Promotion Lane
id: fastlane_lane
if: "!contains(github.ref_name, '-internal')"
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: 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
TAG_NAME="${{ github.ref_name }}"
if [[ "$TAG_NAME" == *"-closed"* ]]; then
echo "lane=closed" >> $GITHUB_OUTPUT
elif [[ "$TAG_NAME" == *"-open"* ]]; then
echo "lane=open" >> $GITHUB_OUTPUT
else
echo "lane=production" >> $GITHUB_OUTPUT
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'
if: "steps.fastlane_lane.outputs.lane != ''"
env:
VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ env.INTERNAL_VERSION_CODE }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_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: Upload Google AAB artifact
if: needs.prepare-release-environment.outputs.exists == 'false'
uses: actions/upload-artifact@v4
with:
name: google-aab
path: app/build/outputs/bundle/googleRelease/app-google-release.aab
retention-days: 1
- 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: Upload Google APK artifact
if: needs.prepare-release-environment.outputs.exists == 'false'
uses: actions/upload-artifact@v4
with:
name: google-apk
path: app/build/outputs/apk/google/release/app-google-release.apk
retention-days: 1
- name: Post Dry Run PR Comment
if: github.event.inputs.dry_run == 'true' && github.event.inputs.pr_number != ''
- name: Attest Google artifacts provenance
if: needs.prepare-release-environment.outputs.exists == 'false'
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: needs.prepare-release-environment.outputs.exists == 'false'
runs-on: ubuntu-latest
needs: [prepare-build-info, prepare-release-environment]
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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
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)"
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Build F-Droid 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 fdroid_build
- name: Upload F-Droid APK artifact
uses: actions/upload-artifact@v4
with:
name: fdroid-apk
path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk
retention-days: 1
- name: Attest F-Droid APK provenance
uses: actions/attest-build-provenance@v3
with:
subject-path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk
manage-github-release:
if: github.event.inputs.dry_run != 'true'
runs-on: ubuntu-latest
needs: [prepare-build-info, check-internal-release, release-google]
needs: [prepare-build-info, prepare-release-environment, release-google, release-fdroid]
steps:
- name: Download all artifacts
if: needs.check-internal-release.outputs.exists == 'false'
if: needs.prepare-release-environment.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'
if: needs.prepare-release-environment.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 }}
name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}-internal
generate_release_notes: true
files: ./artifacts/*/*
draft: true
@ -493,19 +296,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }}
name: ${{ steps.release_name.outputs.name }}
name: ${{ github.ref_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

@ -1,83 +1,70 @@
# Meshtastic-Android Release Process (Condensed)
# Release Process
This guide summarizes the steps for releasing a new version of Meshtastic-Android. The process is automated via GitHub Actions and Fastlane, triggered by pushing a Git tag from a `release/*` branch.
This document outlines the process for creating and promoting releases for the Meshtastic Android application. The system is designed to be robust, auditable, and highly automated, using a combination of user-facing GitHub Actions "wizards" and a central, tag-triggered "engine".
## Overview
- **Tagging:** Push a tag (`vX.X.X[-track.Y]`) from a `release/*` branch to start the release workflow.
- **CI Automation:** Builds both flavors, uploads to Google Play (correct track), and creates/updates a draft GitHub release.
- **Changelog:** Release notes are auto-generated from PR labels via [`.github/release.yml`](.github/release.yml). Label PRs for accurate changelogs.
- **Draft Release:** All tags for the same base version (e.g., `v2.3.5`) update the same draft release. The release title uses the full tag (e.g., `v2.3.5-internal.1`).
## Philosophy
## Tagging & Tracks
- **Internal:** `vX.X.X-internal.Y`
- **Closed:** `vX.X.X-closed.Y`
- **Open:** `vX.X.X-open.Y`
- **Production:** `vX.X.X`
- Increment `.Y` for fixes/iterations.
- **Git Tag Driven**: The entire release lifecycle is initiated and controlled by pushing version tags to the repository.
- **Automated Engine**: A central workflow (`release.yml`) acts as the engine, listening for new version tags. It handles all the heavy lifting: building, versioning, uploading to Google Play, and managing GitHub Releases.
- **User-Friendly Wizards**: Manually creating tags is discouraged. Instead, two "wizard" workflows (`create-internal-release.yml` and `promote-release.yml`) provide a simple UI in the GitHub Actions tab to guide developers through creating and promoting releases safely.
## Release Steps
1. **Branch:** Create `release/X.X.X` from `main`. Only critical fixes allowed.
2. **Tag & Push:** Tag the release commit and push (see below).
3. **CI:** Wait for CI to finish. Artifacts are uploaded, and a draft GitHub release is created/updated.
4. **Verify:** Check Google Play Console and GitHub draft release.
5. **Promote:** Tag the same commit for the next track as needed.
6. **Finalize:**
- **Production:** Publish the GitHub release, then promote in Google Play Console.
- **Other tracks:** Verify with testers.
7. **Merge:** After production, merge `release/X.X.X` back to `main` and delete the branch.
## Versioning Scheme
## Tagging Example
```bash
# On release branch
git tag v2.3.5-internal.1
git push origin v2.3.5-internal.1
# For fixes:
git tag v2.3.5-internal.2
git push origin v2.3.5-internal.2
# Promote:
git tag v2.3.5-closed.1
git push origin v2.3.5-closed.1
```
Releases follow a semantic versioning scheme, `vX.Y.Z`, with suffixes to denote the release channel and iteration.
## Hotfixes & Patch Releases
- `v2.8.0-internal.1`: An internal build, iteration 1.
- `v2.8.0-closed.1`: A closed testing (Alpha) build.
- `v2.8.0-open.1`: An open testing (Beta) build.
- `v2.8.0`: The final production release.
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.
## The Release Lifecycle
### 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
```
### Step 1: Creating a New Internal Build
### 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.
This is the starting point for any new release, whether it's a brand-new version, a patch, or a hotfix.
## Manual Checklist
- [ ] Verify build in Google Play Console
- [ ] Review and publish GitHub draft release (for production)
- [ ] Merge release branch to main after production
- [ ] Label PRs for changelog accuracy
1. Navigate to the **Actions** tab in the GitHub repository.
2. Select the **"Create Internal Release Tag"** workflow.
3. Click **"Run workflow"**.
4. Fill in the `base_version` field with the version you want to create (e.g., `2.8.0`).
5. Run the workflow.
## Build Attestations & Provenance
**What Happens Automatically:**
All release artifacts are accompanied by explicit GitHub build attestations (provenance). After each artifact is uploaded in the release workflow, a provenance attestation is generated using the `actions/attest-build-provenance` action. This provides cryptographic proof that the artifacts were built by our trusted GitHub Actions workflow, ensuring supply chain integrity and allowing users to verify the origin of each release.
- The wizard calculates the next iteration number (e.g., `.1`, `.2`, etc.) and pushes a new tag to the commit (e.g., `v2.8.0-internal.1`).
- The push triggers the `release.yml` engine, which builds the application, uploads it to the Google Play **Internal** track, and creates a corresponding **draft pre-release** on GitHub.
- Attestations are generated immediately after each artifact upload in the workflow.
- You can view and verify provenance in the GitHub UI under each release asset.
- For more details, see [GitHub's documentation on build provenance](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#provenance-attestations).
### Step 2: Promoting an Existing Build
> **Note:** The GitHub release is always attached to the base version tag (e.g., `v2.3.5`). All track tags for the same version update the same draft release. Look for the draft under the base version tag.
Once an internal build has been tested and is ready for a wider audience, you promote it.
> **Best Practice:** Always promote the last verified build from the previous track to the next track. Do not introduce new changes between tracks unless absolutely necessary. This ensures consistency, traceability, and minimizes risk.
1. Navigate to the **Actions** tab in the GitHub repository.
2. Select the **"Promote Release"** workflow.
3. Click **"Run workflow"**.
4. Specify the `target_stage` (`closed`, `open`, or `production`). The default, `auto`, will automatically promote to the next logical stage.
5. Optionally, specify the `base_version` to promote. If left blank, the wizard will find the latest internal tag and use its base version.
6. Run the workflow.
**What Happens Automatically:**
- The wizard determines the correct commit from the latest internal tag for that `base_version`.
- It pushes a new promotion tag (e.g., `v2.8.0-closed.1`) to that commit.
- The push triggers the `release.yml` engine. It intelligently **skips the build steps** and proceeds to:
- Promote the build on Google Play to the target track.
- Update the existing draft GitHub Release, renaming it and marking it as a non-draft pre-release (or full release for production).
### Special Case: Hotfixes / Superseding a Release
The system is designed to handle hotfixes gracefully. If `v2.8.0-internal.1` has been created, but a critical bug is found, the process is simple:
1. Merge the fix into your main branch.
2. Go to the **"Create Internal Release Tag"** workflow again.
3. Enter the *same* `base_version`: `2.8.0`.
**What Happens Automatically:**
- The wizard creates and pushes a new tag, `v2.8.0-internal.2`, to the **new commit**.
- The `release.yml` engine detects that an existing release for `v2.8.0` points to an *older* commit.
- It correctly interprets this as a "superseding" event. It **automatically deletes the old GitHub release and its base tag**, effectively restarting the release process for `v2.8.0` from the new, corrected commit. This prevents a broken or outdated build from ever being promoted.

Wyświetl plik

@ -104,11 +104,11 @@ platform :android do
lane :get_highest_version_code do
require 'set'
all_codes = Set.new
tracks = ['internal', 'closed', 'open', 'production']
tracks = ['internal', 'alpha', 'beta', '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))
codes = google_play_track_version_codes(track: track)
all_codes.merge(codes.map(&:to_i)) if codes
rescue => e
UI.message("Could not fetch version codes for track #{track}: #{e.message}")
end
@ -118,17 +118,6 @@ platform :android do
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",