This commit is contained in:
James Rich 2025-10-04 06:07:43 -05:00 committed by GitHub
parent 28de377068
commit 8b4397a825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 115 additions and 606 deletions

View file

@ -1,93 +0,0 @@
name: Create Internal Release Tag
on:
workflow_dispatch:
inputs:
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
dry_run:
description: "If true, calculate but do not push tag"
required: false
default: "false"
permissions:
contents: write
actions: write
jobs:
create-internal-tag:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Compute Tag
id: tag
run: |
set -euo pipefail
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=$FINAL_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: Configure Git User
if: ${{ inputs.dry_run != 'true' }}
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Create and Push Tag
if: ${{ inputs.dry_run != 'true' }}
run: |
TAG='${{ steps.tag.outputs.internal_tag }}'
MSG="Internal build iteration for ${TAG}"
git tag -a "$TAG" -m "$MSG"
git push origin "$TAG"
echo "Created and pushed $TAG"
- name: Trigger Release Workflow for Tag
if: ${{ inputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG='${{ steps.tag.outputs.internal_tag }}'
echo "Dispatching release workflow for $TAG"
for i in {1..5}; do
if gh api \
-X POST \
-H "Accept: application/vnd.github+json" \
repos/${{ github.repository }}/actions/workflows/release.yml/dispatches \
-f ref="$TAG"; then
echo "Triggered release workflow for $TAG"
break
fi
echo "Retry $i/5 in 5s..."
sleep 5
done
- name: Output Summary
run: |
echo "### Internal Tag Created" >> $GITHUB_STEP_SUMMARY
echo "Tag: ${{ steps.tag.outputs.internal_tag }}" >> $GITHUB_STEP_SUMMARY
echo "Base Version: ${{ inputs.base_version }}" >> $GITHUB_STEP_SUMMARY
echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY
if [ "${{ inputs.dry_run }}" != "true" ]; then
echo "Release workflow dispatched for tag ${{ steps.tag.outputs.internal_tag }}" >> $GITHUB_STEP_SUMMARY
fi

View file

@ -1,273 +0,0 @@
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
actions: 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 }}'
BASE='${{ steps.base.outputs.base_version }}'
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; }
# default outputs
TARGET_STAGE=""
if [ "$REQ" = auto ]; then
CUR_IDX=$(idx "$CUR")
# Auto supports same-stage increments for closed/open when a newer internal exists
if [ "$CUR" = closed ] || [ "$CUR" = open ]; then
LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1 || true)
if [ -n "$LATEST_INTERNAL" ]; then
INTERNAL_COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL")
LATEST_CUR_TAG=$(git tag --list "v${BASE}-${CUR}.*" --sort=-version:refname | head -n1 || true)
if [ -n "$LATEST_CUR_TAG" ]; then
CUR_COMMIT=$(git rev-list -n1 "$LATEST_CUR_TAG")
if [ "$INTERNAL_COMMIT" != "$CUR_COMMIT" ]; then
TARGET_STAGE="$CUR"
fi
fi
fi
fi
if [ -z "$TARGET_STAGE" ]; then
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
fi
else
TARGET_STAGE=$REQ
CUR_IDX=$(idx "$CUR")
REQ_IDX=$(idx "$TARGET_STAGE")
if [ "$TARGET_STAGE" = "$CUR" ]; then
# Same-stage request. Allow closed/open re-promotion only if a newer internal commit exists.
if [ "$TARGET_STAGE" = production ]; then
echo "Cannot re-promote to production for the same base; production tag v${BASE} is unique." >&2
exit 1
fi
# Compute latest internal commit for base
LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1 || true)
if [ -z "$LATEST_INTERNAL" ]; then
echo "No internal tag found for base $BASE (unexpected)." >&2
exit 1
fi
INTERNAL_COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL")
# Compute latest tag commit for this stage
LATEST_STAGE_TAG=$(git tag --list "v${BASE}-${TARGET_STAGE}.*" --sort=-version:refname | head -n1 || true)
if [ -z "$LATEST_STAGE_TAG" ]; then
# No prior tag for this stage even though CUR==TARGET_STAGE; proceed to create .1 normally
:
else
STAGE_COMMIT=$(git rev-list -n1 "$LATEST_STAGE_TAG")
if [ "$INTERNAL_COMMIT" = "$STAGE_COMMIT" ]; then
echo "Requested re-promotion to $TARGET_STAGE but latest internal commit matches latest ${TARGET_STAGE} tag ($LATEST_STAGE_TAG). Nothing new to promote." >&2
exit 1
fi
fi
# Allowed: TARGET_STAGE remains as requested (closed/open)
else
# Different stage request; must be ahead of current unless allow_skip permits skipping
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
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: Configure Git User
if: ${{ inputs.dry_run != 'true' }}
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- 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: Trigger Release Workflow for Tag
if: ${{ inputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG='${{ steps.tag.outputs.new_tag }}'
echo "Dispatching release workflow for $TAG"
for i in {1..5}; do
if gh api \
-X POST \
-H "Accept: application/vnd.github+json" \
repos/${{ github.repository }}/actions/workflows/release.yml/dispatches \
-f ref="$TAG"; then
echo "Triggered release workflow for $TAG"
break
fi
echo "Retry $i/5 in 5s..."
sleep 5
done
- 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
if [ "${{ inputs.dry_run }}" != "true" ]; then
echo "Release workflow dispatched for tag ${{ steps.tag.outputs.new_tag }}" >> $GITHUB_STEP_SUMMARY
fi

View file

@ -22,7 +22,6 @@ jobs:
outputs:
APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
BASE_TAG: ${{ steps.get_base_tag.outputs.BASE_TAG }}
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -46,12 +45,6 @@ jobs:
id: get_version_name
run: echo "APP_VERSION_NAME=$(echo ${GITHUB_REF_NAME#v} | sed 's/-.*//')" >> $GITHUB_OUTPUT
- name: Get Base Tag (for release/artifact naming)
id: get_base_tag
run: |
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: |
@ -66,78 +59,11 @@ jobs:
VERSION_CODE=$((COMMIT_COUNT + OFFSET))
echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT
shell: bash
prepare-release-environment:
runs-on: ubuntu-latest
needs: prepare-build-info
outputs:
exists: ${{ steps.check_and_clean.outputs.exists }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- 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)
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 "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
check-versioncode-google-play:
runs-on: ubuntu-latest
needs: prepare-build-info
outputs:
exists: ${{ steps.check_versioncode.outputs.exists }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Check if versionCode exists on Google Play Internal Track
id: check_versioncode
env:
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
set -euo pipefail
echo "$GOOGLE_PLAY_JSON_KEY" > /tmp/play-store-credentials.json
exists=false
if bundle exec fastlane supply --help | grep -q -- '--json'; then
JSON=$(bundle exec fastlane supply --json_key /tmp/play-store-credentials.json --package_name com.geeksville.mesh --track internal --json || true)
if echo "$JSON" | jq -e ".tracks.internal.releases[].versionCodes[] | select(. == ${VERSION_CODE})" >/dev/null 2>&1; then
exists=true
fi
else
# Fallback: use fastlane action output and parse array from stdout
OUTFILE=$(mktemp)
bundle exec fastlane run google_play_track_version_codes json_key:/tmp/play-store-credentials.json package_name:com.geeksville.mesh track:internal --capture_output | tee "$OUTFILE" || true
ARR=$(grep -oE '\\[[^]]*\\]' "$OUTFILE" | head -n1)
if echo "$ARR" | tr -d '[] ' | tr ',' '\n' | grep -x "${VERSION_CODE}" >/dev/null 2>&1; then
exists=true
fi
fi
echo "exists=${exists}" >> $GITHUB_OUTPUT
# This matches the reproducible versionCode strategy: versionCode = git commit count + offset
release-google:
runs-on: ubuntu-latest
needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play]
needs: prepare-build-info
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -150,6 +76,7 @@ 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 }}
@ -183,26 +110,13 @@ jobs:
ruby-version: '3.2'
bundler-cache: true
- name: Build and Deploy to Internal Track
if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'false'
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 internal
- name: Build Google artifacts without upload
if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'true'
env:
VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
run: ./gradlew clean bundleGoogleRelease assembleGoogleRelease -Pandroid.injected.version.name=${VERSION_NAME} -Pandroid.injected.version.code=${VERSION_CODE}
- name: Determine Fastlane Promotion Lane
- name: Determine Fastlane Lane
id: fastlane_lane
if: "!contains(github.ref_name, '-internal')"
run: |
TAG_NAME="${{ github.ref_name }}"
if [[ "$TAG_NAME" == *"-closed"* ]]; then
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
@ -210,15 +124,14 @@ jobs:
echo "lane=production" >> $GITHUB_OUTPUT
fi
- name: Promote on Google Play
if: "steps.fastlane_lane.outputs.lane != '' && needs.check-versioncode-google-play.outputs.exists == 'true'"
- 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: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal')
if: contains(github.ref_name, '-internal')
uses: actions/upload-artifact@v4
with:
name: google-aab
@ -226,7 +139,7 @@ jobs:
retention-days: 1
- name: Upload Google APK artifact
if: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal')
if: contains(github.ref_name, '-internal')
uses: actions/upload-artifact@v4
with:
name: google-apk
@ -234,7 +147,7 @@ jobs:
retention-days: 1
- name: Attest Google artifacts provenance
if: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal')
if: contains(github.ref_name, '-internal')
uses: actions/attest-build-provenance@v3
with:
subject-path: |
@ -242,9 +155,9 @@ jobs:
app/build/outputs/apk/google/release/app-google-release.apk
release-fdroid:
if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'false'
if: contains(github.ref_name, '-internal')
runs-on: ubuntu-latest
needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play]
needs: prepare-build-info
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -297,22 +210,21 @@ jobs:
with:
subject-path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk
manage-github-release:
create-internal-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play, release-google, release-fdroid]
needs: [prepare-build-info, release-google, release-fdroid]
if: contains(github.ref_name, '-internal')
steps:
- name: Download all artifacts
if: needs.prepare-release-environment.outputs.exists == 'false'
uses: actions/download-artifact@v5
with:
path: ./artifacts
- name: Create GitHub Release
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: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}-internal
tag_name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: ./artifacts/*/*
draft: true
@ -320,13 +232,32 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Promote GitHub Release
if: "!contains(github.ref_name, '-internal')"
promote-release:
runs-on: ubuntu-latest
needs: [prepare-build-info, release-google]
if: "!contains(github.ref_name, '-internal')"
steps:
- name: Determine Release Properties
id: release_properties
run: |
TAG_NAME="${{ github.ref_name }}"
if [[ "$TAG_NAME" == *"-closed"* ]]; then
echo "draft=false" >> $GITHUB_OUTPUT
echo "prerelease=true" >> $GITHUB_OUTPUT
elif [[ "$TAG_NAME" == *"-open"* ]]; then
echo "draft=false" >> $GITHUB_OUTPUT
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "draft=false" >> $GITHUB_OUTPUT
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Update GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }}
tag_name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(github.ref_name, '-closed') || contains(github.ref_name, '-open') }}
draft: ${{ steps.release_properties.outputs.draft }}
prerelease: ${{ steps.release_properties.outputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,70 +1,58 @@
# Release Process
# Meshtastic-Android Release Process (Condensed)
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".
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.
## Philosophy
## 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`).
- **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.
## 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.
## Versioning Scheme
## 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.
Releases follow a semantic versioning scheme, `vX.Y.Z`, with suffixes to denote the release channel and iteration.
## 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
```
- `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.
## 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
---
## Build Attestations & Provenance
## The Release Lifecycle
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.
### Step 1: Creating a New Internal Build
- 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).
This is the starting point for any new release, whether it's a brand-new version, a patch, or a hotfix.
> **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.
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.
**What Happens Automatically:**
- 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.
### Step 2: Promoting an Existing Build
Once an internal build has been tested and is ready for a wider audience, you promote it.
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.
> **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.

View file

@ -15,7 +15,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.datadog.gradle.plugin.InstrumentationMode
import com.geeksville.mesh.buildlogic.GitVersionValueSource
import java.io.FileInputStream
import java.util.Properties
@ -257,7 +256,3 @@ dokka {
maxHeapSize = "6g"
}
}
datadog {
composeInstrumentation = InstrumentationMode.AUTO
}

View file

@ -76,13 +76,11 @@ constructor(
analyticsPrefs: AnalyticsPrefs,
) : PlatformAnalytics {
private val sampleRate =
100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
private val isInTestLab: Boolean
get() {
val testLabSetting =
Settings.System.getString(context.contentResolver, "firebase.test.lab")
val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab")
return "true" == testLabSetting
}
@ -187,8 +185,7 @@ constructor(
override fun setDeviceAttributes(firmwareVersion: String, model: String) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
GlobalRumMonitor.get()
.addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
@ -243,8 +240,7 @@ constructor(
private fun String.extractSemanticVersion(): String {
val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?".toRegex()
val matchResult = regex.find(this)
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".")
?: this
return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this
}
override fun track(event: String, vararg properties: DataPair) {
@ -253,16 +249,10 @@ constructor(
when (it.value) {
is Double -> bundle.putDouble(it.name, it.value)
is Int ->
bundle.putLong(
it.name,
it.value.toLong()
) // Firebase expects Long for integer values in bundles
bundle.putLong(it.name, it.value.toLong()) // Firebase expects Long for integer values in bundles
is Long -> bundle.putLong(it.name, it.value)
is Float -> bundle.putDouble(it.name, it.value.toDouble())
is String -> bundle.putString(
it.name,
it.value as String?
) // Explicitly handle String
is String -> bundle.putString(it.name, it.value as String?) // Explicitly handle String
else -> bundle.putString(it.name, it.value.toString()) // Fallback for other types
}
Timber.tag(TAG).d("Analytics: track $event (${it.name} : ${it.value})")

View file

@ -23,33 +23,10 @@ platform :android do
desc "Deploy a new version to the internal track on Google Play"
lane :internal do
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"
lane :closed do
aab_path = build_google_release
upload_to_play_store(
track: 'internal',
track_promote_to: 'NewAlpha',
version_codes: [ENV["VERSION_CODE"].to_i],
aab: aab_path,
release_status: 'completed',
skip_upload_apk: true,
skip_upload_metadata: true,
@ -59,12 +36,25 @@ platform :android do
)
end
desc "Promote from internal track to the open track on Google Play"
lane :open do
desc "Promote from internal track to the closed track on Google Play"
lane :closed do
upload_to_play_store(
track: 'internal',
track_promote_to: 'NewAlpha',
release_status: 'completed',
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)
end
desc "Promote from closed track to the open track on Google Play"
lane :open do
upload_to_play_store(
track: 'NewAlpha',
track_promote_to: 'beta',
version_codes: [ENV["VERSION_CODE"].to_i],
release_status: 'draft',
skip_upload_apk: true,
skip_upload_metadata: true,
@ -74,12 +64,11 @@ platform :android do
)
end
desc "Promote from internal track to the production track on Google Play"
desc "Promote from open track to the production track on Google Play"
lane :production do
upload_to_play_store(
track: 'internal',
track: 'open',
track_promote_to: 'production',
version_codes: [ENV["VERSION_CODE"].to_i],
release_status: 'draft',
skip_upload_apk: true,
skip_upload_metadata: true,
@ -100,24 +89,6 @@ 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', 'alpha', 'beta', 'production']
tracks.each do |track|
begin
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
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
private_lane :build_google_release do
gradle(
task: "clean bundleGoogleRelease assembleGoogleRelease",